Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,21 @@ The agent will:

---

### Agent Persistent State

The agent optionally stores local state in:

agent/agent-state.json

It contains:

- score history
- provider success/failure history
- cumulative spend

If the file is deleted or corrupted, the agent recreates it automatically and continues running.


## Agent Credit Scoring

Lodestar ships a second Soroban contract that gives every AI agent a verifiable on-chain credit score.
Expand Down
67 changes: 64 additions & 3 deletions agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { Keypair } = pkg;
import { x402Client, x402HTTPClient } from '@x402/core/client';
import { createEd25519Signer } from '@x402/stellar';
import { ExactStellarScheme } from '@x402/stellar/exact/client';
import { getAgentState, saveAgentState } from "./state.js";

// ── Config ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -119,7 +120,7 @@ export const EVENT = {
// ── Credit scoring helpers ────────────────────────────────────────────────────

let currentScore = null;

let agentState = null;
export async function ensureRegistered() {
try {
const res = await fetchWithTimeout(`${LODESTAR_API_URL}/api/agents/${AGENT_ADDRESS}`);
Expand All @@ -134,6 +135,7 @@ export async function ensureRegistered() {
const data = await res.json();
const agent = data.agent ?? data;
currentScore = agent.score;
agentState = getAgentState(AGENT_ADDRESS);
const policy = data.policy;
const dailyLimitUsdc = policy
? (Number(BigInt(policy.max_per_day_stroops)) / 10_000_000).toFixed(2)
Expand Down Expand Up @@ -163,6 +165,7 @@ export async function ensureRegistered() {
});
if (regRes.ok) {
currentScore = 100;
agentState = getAgentState(AGENT_ADDRESS);
logger.info(
{ event: EVENT.AGENT_REGISTERED, agentAddress: AGENT_ADDRESS, score: 100, scoringEnabled: true },
'Registered — starting score: 100'
Expand Down Expand Up @@ -217,6 +220,14 @@ async function recordOutcome(amountUsdc, success, serviceId) {
const data = await res.json();
const scoreBefore = currentScore;
currentScore = data.newScore;
if (agentState?.providerHistory) {
agentState.scoreHistory.push({
timestamp: new Date().toISOString(),
score: currentScore,
});

saveAgentState(AGENT_ADDRESS, agentState);
}
logger.info(
{ event: EVENT.SCORE_UPDATED, agentAddress: AGENT_ADDRESS, scoreBefore, scoreAfter: currentScore },
'Score updated'
Expand Down Expand Up @@ -302,7 +313,12 @@ async function submitReputation(id, positive) {
// Weighted random selection: higher reputation = proportionally more likely to be chosen.
// Falls back to uniform random when all weights are zero.
function selectWeighted(services) {
const weights = services.map(s => Math.max(0, s.reputation));
const history = agentState?.providerHistory ?? {};

const weights = services.map((s) => {
const failures = agentState?.providerHistory?.[s.id]?.failures ?? 0;
return Math.max(0, s.reputation / (1 + failures));
});
const total = weights.reduce((sum, w) => sum + w, 0);
if (total === 0) {
return services[Math.floor(Math.random() * services.length)];
Expand All @@ -324,6 +340,7 @@ export async function runTask(category, buildUrl, scoringEnabled, client = httpC
const taskStart = Date.now();
logger.info({ event: EVENT.TASK_START, category, agentAddress: AGENT_ADDRESS }, 'Task started');

agentState = getAgentState(AGENT_ADDRESS);
const services = await fetchServices(category);

if (!services.length) {
Expand All @@ -334,7 +351,17 @@ export async function runTask(category, buildUrl, scoringEnabled, client = httpC
return { success: false, priceUsdc: null };
}

const eligible = services.filter(s => s.reputation >= minReputation);
const providerHistory = agentState?.providerHistory;

const eligible = services.filter((service) => {
if (service.reputation < minReputation) return false;

const history = providerHistory?.[service.id];

if (!history) return true;

return history.failures < 3;
});
if (!eligible.length) {
logger.error(
{ event: EVENT.TASK_START, category, servicesFound: services.length, minReputation },
Expand Down Expand Up @@ -414,6 +441,16 @@ export async function runTask(category, buildUrl, scoringEnabled, client = httpC
'Payment failed — network error'
);
if (scoringEnabled) await recordOutcome(selected.price_usdc, false, selected.id);
if (agentState?.providerHistory) {
const history = agentState.providerHistory[selected.id] ?? {
successes: 0,
failures: 0,
};

history.failures++;
agentState.providerHistory[selected.id] = history;
saveAgentState(AGENT_ADDRESS, agentState);
}
failed.add(selected.id);
continue;
}
Expand All @@ -434,6 +471,16 @@ export async function runTask(category, buildUrl, scoringEnabled, client = httpC
if (scoringEnabled) await recordOutcome(selected.price_usdc, false, selected.id);
// Payment settled but service returned bad data — penalise service reputation.
await submitReputation(selected.id, false);
if (agentState?.providerHistory) {
const history = agentState.providerHistory[selected.id] ?? {
successes: 0,
failures: 0,
};

history.failures++;
agentState.providerHistory[selected.id] = history;
saveAgentState(AGENT_ADDRESS, agentState);
}
Comment on lines 471 to +483

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Count settled endpoint-error payments in cumulative spend.

This branch says the payment has settled, but only increments failures. Retrying another provider then leaves cumulativeSpend lower than the amount actually paid.

Proposed fix
       history.failures++;
       agentState.providerHistory[selected.id] = history;
+      agentState.cumulativeSpend += parseFloat(selected.price_usdc);
       saveAgentState(AGENT_ADDRESS, agentState);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (scoringEnabled) await recordOutcome(selected.price_usdc, false, selected.id);
// Payment settled but service returned bad data — penalise service reputation.
await submitReputation(selected.id, false);
if (agentState?.providerHistory) {
const history = agentState.providerHistory[selected.id] ?? {
successes: 0,
failures: 0,
};
history.failures++;
agentState.providerHistory[selected.id] = history;
saveAgentState(AGENT_ADDRESS, agentState);
}
if (scoringEnabled) await recordOutcome(selected.price_usdc, false, selected.id);
// Payment settled but service returned bad data — penalise service reputation.
await submitReputation(selected.id, false);
if (agentState?.providerHistory) {
const history = agentState.providerHistory[selected.id] ?? {
successes: 0,
failures: 0,
};
history.failures++;
agentState.providerHistory[selected.id] = history;
agentState.cumulativeSpend += parseFloat(selected.price_usdc);
saveAgentState(AGENT_ADDRESS, agentState);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/agent.js` around lines 408 - 420, Update the settled endpoint-error
branch near submitReputation to add the paid amount to cumulativeSpend before
retrying another provider. Reuse the existing cumulative-spend update mechanism
and selected.price_usdc value, while preserving the current failure tracking and
state persistence behavior.

failed.add(selected.id);
continue;
}
Expand All @@ -458,6 +505,20 @@ export async function runTask(category, buildUrl, scoringEnabled, client = httpC

await submitReputation(selected.id, true);

if (agentState?.providerHistory) {
const history = agentState.providerHistory[selected.id] ?? {
successes: 0,
failures: 0,
};

history.successes++;
agentState.providerHistory[selected.id] = history;

agentState.cumulativeSpend += parseFloat(selected.price_usdc);

saveAgentState(AGENT_ADDRESS, agentState);
}

return { success: true, priceUsdc: selected.price_usdc };
}

Expand Down
14 changes: 12 additions & 2 deletions agent/agent.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';

import fs from 'fs';
// ── Hoisted mock refs (available inside vi.mock factories) ────────────────────

const { logInfo, logWarn, logError, logDebug } = vi.hoisted(() => ({
Expand Down Expand Up @@ -92,8 +92,18 @@ function buildFetch({ services = [MOCK_SERVICE], canSpend = true, endpointOk = t
beforeEach(() => {
vi.clearAllMocks();
global.fetch = buildFetch();
});

fs.writeFileSync(
'agent-state.json',
JSON.stringify({
GAGENTADDRESSMOCK000000000000000000000000000000000000000: {
scoreHistory: [],
providerHistory: {},
cumulativeSpend: 0,
},
})
);
});
describe('runTask — happy path', () => {
it('logs task_start with category field', async () => {
await runTask('weather', (ep) => ep, true, mockHttpClient);
Expand Down
52 changes: 52 additions & 0 deletions agent/state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const STATE_FILE = path.join(__dirname, "agent-state.json");

function readState() {
if (!fs.existsSync(STATE_FILE)) {
return {};
}

try {
const raw = fs.readFileSync(STATE_FILE, "utf8");
return JSON.parse(raw);
} catch (err) {
console.warn("Ignoring corrupt state file:", err.message);
return {};
Comment on lines +15 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Recover from schema-invalid state as well as invalid JSON.

null parses successfully but makes state[agentAddress] throw. Partial entries such as { "address": { "providerHistory": {} } } later make agent/agent.js attempt scoreHistory.push(...) on undefined. Validate the top-level object and normalize every agent entry before returning it.

Proposed fix
+function isRecord(value) {
+  return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
 function readState() {
   ...
-    return JSON.parse(raw);
+    const state = JSON.parse(raw);
+    if (!isRecord(state)) throw new Error("State must be an object");
+    return state;
   ...
 }

 export function getAgentState(agentAddress) {
   const state = readState();
-
-  if (!state[agentAddress]) {
+  const saved = state[agentAddress];
+  if (
+    !isRecord(saved) ||
+    !Array.isArray(saved.scoreHistory) ||
+    !isRecord(saved.providerHistory) ||
+    !Number.isFinite(saved.cumulativeSpend)
+  ) {
     state[agentAddress] = {
-      scoreHistory: [],
-      providerHistory: {},
-      cumulativeSpend: 0,
+      scoreHistory: Array.isArray(saved?.scoreHistory) ? saved.scoreHistory : [],
+      providerHistory: isRecord(saved?.providerHistory) ? saved.providerHistory : {},
+      cumulativeSpend: Number.isFinite(saved?.cumulativeSpend) ? saved.cumulativeSpend : 0,
     };
     writeState(state);
   }

Also applies to: 35-42

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 15-15: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.readFileSync(STATE_FILE, "utf8")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/state.js` around lines 15 - 20, Update the state-loading logic around
the JSON.parse flow to validate that the parsed value is a non-null object, then
normalize each agent entry so required nested fields such as providerHistory are
initialized to the shapes expected by agent/agent.js, including an array for
scoreHistory. Treat invalid or schema-incompatible state like corrupt input by
returning a safe normalized empty/default state, while preserving valid entries
and the existing warning behavior.

}
}

function writeState(state) {
fs.writeFileSync(
STATE_FILE,
JSON.stringify(state, null, 2),
"utf8"
);
}

export function getAgentState(agentAddress) {
const state = readState();

if (!state[agentAddress]) {
state[agentAddress] = {
scoreHistory: [],
providerHistory: {},
cumulativeSpend: 0,
};

writeState(state);
}

return state[agentAddress];
}

export function saveAgentState(agentAddress, agentState) {
const state = readState();
state[agentAddress] = agentState;
writeState(state);
}
47 changes: 20 additions & 27 deletions backend/src/routes/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,36 +77,21 @@ function parsePositiveSafeInteger(value) {
// Appends ttl_warning:true when the entry's estimated remaining TTL falls
// below SERVICE_TTL_WARNING_LEDGERS. Omits the field entirely when currentLedger
// is unavailable so callers can always treat absence as "no warning data".
function parseFiniteNumericValue(value) {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}

if (typeof value !== "string" || value.trim() === "") {
return null;
}

const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}

function annotateTtlWarning(service, currentLedger) {
const parsedCurrentLedger = parseFiniteNumericValue(currentLedger);
const registeredAt = parseFiniteNumericValue(service?.registered_at);

if (parsedCurrentLedger === null || registeredAt === null) {
return { ...service };
}

const expiryLedger = registeredAt + SERVICE_MAX_TTL;
const warningOnset = expiryLedger - SERVICE_TTL_WARNING_LEDGERS;
if (currentLedger == null) return service;

return {
...service,
ttl_warning: parsedCurrentLedger >= warningOnset,
ttl_warning:
currentLedger >=
service.registered_at +
SERVICE_MAX_TTL -
SERVICE_TTL_WARNING_LEDGERS,
};
}
Comment on lines +77 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the inclusive TTL threshold.

The predicate uses >=, so ttl_warning is emitted when the remaining TTL is at or below SERVICE_TTL_WARNING_LEDGERS, not only when it is below. Update the comment to match the tested behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/registry.js` around lines 64 - 78, Update the comment
above annotateTtlWarning to state that ttl_warning:true is appended when the
estimated remaining TTL is at or below SERVICE_TTL_WARNING_LEDGERS, reflecting
the inclusive >= predicate; keep the unavailable-currentLedger behavior
unchanged.


// Appends ttl_warning:true when the entry's estimated remaining TTL falls
// below SERVICE_TTL_WARNING_LEDGERS. Omits the field entirely when currentLedger
// is unavailable so callers can always treat absence as "no warning data".
router.get("/services", async (req, res) => {
try {
const { category, q, offset: offsetStr, limit: limitStr } = req.query;
Expand Down Expand Up @@ -190,10 +175,18 @@ router.get("/services/:id", async (req, res) => {

const currentLedger =
ledgerResult.status === "fulfilled" ? ledgerResult.value : null;
res.json(annotateTtlWarning(service, currentLedger));

const response = annotateTtlWarning(service, currentLedger);



res.json(response);
} catch (err) {
logger.error({ err }, "GET /api/services/:id failed");
res.status(500).json({ error: "Failed to fetch service", code: "FETCH_ERROR" });
logger.error({ err }, "GET /api/services/:id failed");
res.status(500).json({
error: "Failed to fetch service",
code: "FETCH_ERROR",
});
}
});

Expand Down
6 changes: 6 additions & 0 deletions backend/src/routes/registry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ describe('GET /api/services', () => {

const res = await request(app).get('/api/services');

console.log("status:", res.status);
console.log("body:", res.body);

expect(res.status).toBe(200);
expect(res.body.services).toHaveLength(2);
expect(res.body.count).toBe(2);
Expand Down Expand Up @@ -1032,6 +1035,9 @@ describe('GET /api/services/:id — ttl_warning annotation', () => {

const res = await request(app).get('/api/services/1');

console.log("STATUS:", res.status);
console.log("BODY:", res.body);

expect(res.status).toBe(200);
expect('ttl_warning' in res.body).toBe(false);
});
Expand Down
7 changes: 4 additions & 3 deletions frontend/components/ServiceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ export default function ServiceCard({ service, onReputationChange }: Props) {
}
}

const ledger = service.registered_at != null
? `Ledger #${Number(service.registered_at).toLocaleString()}`
: null;
const ledger =
service.registered_at != null
? `Ledger #${service.registered_at.toLocaleString()}`
: "Ledger unavailable";

return (
<div className="card p-6 flex flex-col gap-4 fade-in">
Expand Down
19 changes: 7 additions & 12 deletions frontend/lib/sort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,16 @@ function makeAgent(overrides: Partial<AgentEntry> = {}): AgentEntry {
return {
address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWFL',
name: 'Test Agent',
description: 'A test agent',
owner: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWFL',
score: 100,
endpoint: 'https://example.com',
score: 0,
total_payments: '0',
successful_payments: '0',
failed_payments: '0',
total_volume_stroops: '0',
registered_at: '100',
last_active: '100',
active: true,
flagged: false,
flag_reason: '',
registered_at: '0',
...overrides,
};
}describe('sortServices', () => {
}


describe('sortServices', () => {
it('sorts by newest (registered_at descending)', () => {
const services = [
makeService({ id: 1, registered_at: 100 }),
Expand Down
Loading