Skip to content

Commit b200d29

Browse files
committed
feat(agents): bounded tool loops for buyer award, verifier verdict, seller bid-gate, and verify-gate decisions
Extends the seller bid-pricing tool loop pattern (decideBid) to the rest of the round: pickWinner() and checkDelivery()'s LLM branch now run through runToolLoop with a deterministic fallback, same as pricing already does. Adds two new decision points gated behind opt-in env vars (REPUTATION_URL, VERIFY_GATE_ENABLED): a seller bid-gate that can decline to bid based on its own track record, and a buyer verify-gate that can skip escalation for sellers with a clean, established record. Every new surface is a no-op by default so the live wire trace is unchanged unless explicitly configured. Reuses the ledger's existing reputation() rather than building new shared state. Full design rationale in docs/E2E_AGENTIC_DECISIONS.md; this commit implements D3/D4/D7/D12/D13 of that plan, scoped down per docs/BUYER_VERIFIER_LOOPS.md.
1 parent 9676b63 commit b200d29

25 files changed

Lines changed: 1148 additions & 122 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,9 @@ Harness processes hold no signing keys. Agents call policy before deposits and r
230230
| [CORAL.md](CORAL.md) | CoralOS session/agent mechanics. |
231231
| [LLM.md](LLM.md) | Provider selection, model override, fallback behavior. |
232232
| [API.md](API.md) | Service-agnostic API usage guide for any integration. |
233-
| [docs/AGENT_DEPTH_PLAN.md](docs/AGENT_DEPTH_PLAN.md) | Bounded tool-calling loop and adversarial review. |
233+
| [docs/AGENT_DEPTH_PLAN.md](docs/AGENT_DEPTH_PLAN.md) | Bounded tool-calling loop and adversarial review, plus the longer autonomy roadmap. |
234+
| [docs/E2E_AGENTIC_DECISIONS.md](docs/E2E_AGENTIC_DECISIONS.md) | Concrete lifecycle decisions behind the roadmap's later phases, and what each one actually weighs. |
235+
| [docs/BUYER_VERIFIER_LOOPS.md](docs/BUYER_VERIFIER_LOOPS.md) | Scoped, ready-to-build plan: buyer award loop + verifier loop only. |
234236
| [docs/PRODUCTION_READINESS.md](docs/PRODUCTION_READINESS.md) | Readiness status, promotion checklist, open blockers. |
235237
| [docs/PAYMENT_RAIL_INTEGRATION.md](docs/PAYMENT_RAIL_INTEGRATION.md) | Deployed rails and where each is wired. |
236238
| [TXODDS.md](TXODDS.md) | TxODDS TxLINE integration details. |

coral-agents/buyer-agent/README.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,36 @@ WANT -> BID* -> AWARD
2121

2222
| File | Role |
2323
|---|---|
24-
| `src/index.ts` | Market loop and policy-gated deposit/release. |
25-
| `src/arbiter.ts` | Arbiter client and vault PDA helpers. |
26-
| `src/escrow.ts` | Direct base escrow client. |
27-
| `src/wantFeed.ts` | Event-mode polling for external jobs. |
28-
| `src/reputation.ts` | Reputation lines from feed API. |
29-
| `src/goal.ts` | Buyer goal defaults. |
30-
| `src/llm_buyer.ts` | Award reasoning and fallback selection. |
24+
| `src/index.ts` | Market loop: WANT/BID/AWARD, policy-gated deposit, wait for DELIVERED, optional VERIFY, policy-gated release. |
25+
| `src/award.ts` | `pickWinner()` — award selection via a bounded tool loop, deterministic cheapest-bid fallback. See Award Loop below. |
26+
| `src/award-tools.ts` | Tools for the award loop: `fetch_seller_reputation`, `compute_value_score`, `submit_award`. |
27+
| `src/escrow.ts` | Direct base escrow client (`SETTLEMENT_MODE=direct`). |
28+
| `src/arbiter.ts` | Arbiter client and vault PDA helpers (`SETTLEMENT_MODE=arbiter`, the default). |
29+
| `src/wantFeed.ts` | Event-mode polling for external jobs (`WANT_FEED_URL`), instead of rotating `BUYER_ARGS`. |
30+
| `src/reputation.ts` | Fetches per-seller reputation (structured and formatted) from the feed API (`REPUTATION_URL`). |
31+
| `src/verify-gate.ts` | `decideVerifyEscalation()` — per-round judgment on whether to actually escalate to the verifier. See Verify Gate below. |
32+
33+
## Award Loop
34+
35+
`pickWinner()` (`src/award.ts`) runs a bounded tool-calling loop (`runToolLoop`, capped at 5 rounds)
36+
instead of a single LLM call: the model calls `fetch_seller_reputation` and `compute_value_score`
37+
(a deterministic price × reputation formula) before it must call `submit_award` to terminate. If the
38+
loop errors, exhausts its rounds, or picks a seller outside the collected bid pool, the buyer falls
39+
back to the cheapest bid — same failure-mode shape as the seller's bid decision loop
40+
(`coral-agents/seller-agent/README.md`'s Bid Decision Loop).
41+
42+
## Verify Gate
43+
44+
`decideVerifyEscalation()` (`src/verify-gate.ts`) decides per round whether to actually send
45+
`VERIFY`, instead of the static `VERIFIER_AGENT` on/off used every round when `VERIFY_GATE_ENABLED`
46+
is unset. It only skips escalation for a seller with an established (3+ delivery), clean
47+
(`verifiedFail === 0`) record — otherwise it always escalates.
48+
49+
**Important**: skipping escalation does not safely bypass release policy — `policy.ts`'s
50+
`requireVerifier` is hardcoded to `!!VERIFIER_AGENT`, so a skipped round without a `VERIFIED pass`
51+
has its release denied exactly like a verifier timeout: funds stay in escrow, refundable after the
52+
deadline. This is an opt-in efficiency/scrutiny tradeoff, not a free win — see `docs/E2E_AGENTIC_DECISIONS.md`'s
53+
D4 for the full reasoning. Off by default.
3154

3255
## Environment
3356

@@ -43,8 +66,9 @@ WANT -> BID* -> AWARD
4366
| `SETTLEMENT_MODE` | `arbiter` or `direct`. |
4467
| `VERIFIER_AGENT` | Enables verifier gate when set. |
4568
| `VERIFY_WINDOW_MS` | Verifier response window. |
69+
| `VERIFY_GATE_ENABLED` | Set to `1` to skip escalation for sellers with a clean, established record — see Verify Gate above. Default `0` (verify every delivery). |
4670
| `WANT_FEED_URL` | Event-mode job source. |
47-
| `REPUTATION_URL` | Feed reputation endpoint. |
71+
| `REPUTATION_URL` | Feed reputation endpoint — folded into award selection and, when `VERIFY_GATE_ENABLED=1`, the verify-gate decision. |
4872
| `POLICY_MAX_SOL_PER_ROUND` | Policy spend cap per round. |
4973
| `POLICY_MAX_SOL_PER_SESSION` | Policy spend cap per session. |
5074
| `POLICY_SERVICES` | Allowed services. |

coral-agents/buyer-agent/coral-agent.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ SOLANA_RPC_URL = { type = "string", default = "", description = "Solana RPC URL
3131
SETTLEMENT_MODE = { type = "string", default = "arbiter", description = "arbiter (default) | direct legacy base escrow" }
3232
VERIFIER_AGENT = { type = "string", default = "", description = "Name of the independent verifier agent; when set, release is gated on its VERIFIED pass" }
3333
VERIFY_WINDOW_MS = { type = "f64", default = 20000, description = "How long to wait for the verifier's verdict before leaving funds in escrow" }
34+
VERIFY_GATE_ENABLED = { type = "string", default = "0", description = "Set to 1 to skip escalation for sellers with a clean, established record. WARNING: a wrong skip forfeits that round's settlement, not just verification - see verify-gate.ts" }
3435
WANT_FEED_URL = { type = "string", default = "", description = "Event mode: poll this URL for the next job (research market watcher) instead of rotating BUYER_ARGS" }
3536
POLICY_MAX_SOL_PER_ROUND = { type = "f64", default = 0, description = "Spend cap per deposit in SOL (0/unset = the round budget)" }
3637
POLICY_MAX_SOL_PER_SESSION = { type = "f64", default = 0, description = "Cumulative session spend cap in SOL (unset = uncapped)" }
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it, expect } from 'vitest'
2+
import type { SellerReputation } from '@pay/agent-runtime'
3+
import { fetchSellerReputationTool, computeValueScoreTool, submitAwardTool } from './award-tools.js'
4+
5+
const reps: SellerReputation[] = [
6+
{ seller: 'seller-good', awarded: 5, delivered: 5, settled: 5, verifiedPass: 5, verifiedFail: 0, refunded: 0, score: 95 },
7+
]
8+
9+
describe('fetchSellerReputationTool', () => {
10+
it('returns the closed-over reputation array', async () => {
11+
const tool = fetchSellerReputationTool(reps)
12+
expect(await tool.execute({})).toEqual({ available: true, reputation: reps })
13+
})
14+
15+
it('reports unavailable for an empty array', async () => {
16+
const tool = fetchSellerReputationTool([])
17+
expect(await tool.execute({})).toEqual({ available: false, reputation: [] })
18+
})
19+
})
20+
21+
describe('computeValueScoreTool', () => {
22+
it('weighs price and reputation for a known seller', async () => {
23+
const tool = computeValueScoreTool(0.001, reps)
24+
const out = await tool.execute({ by: 'seller-good', priceSol: 0.0005 })
25+
// priceScore = 100 - min(1, 0.5)*100 = 50; valueScore = round(0.6*50 + 0.4*95) = 68
26+
expect(out).toEqual({ by: 'seller-good', priceSol: 0.0005, valueScore: 68, repScore: 95, withinBudget: true })
27+
})
28+
29+
it('defaults an unknown seller to a neutral 50 reputation score, never zero', async () => {
30+
const tool = computeValueScoreTool(0.001, reps)
31+
const out = await tool.execute({ by: 'seller-new', priceSol: 0.0005 })
32+
expect(out.repScore).toBe(50)
33+
})
34+
35+
it('scores an over-budget price as zero on the price component', async () => {
36+
const tool = computeValueScoreTool(0.001, reps)
37+
const out = await tool.execute({ by: 'seller-good', priceSol: 0.002 })
38+
expect(out.withinBudget).toBe(false)
39+
expect(out.valueScore).toBe(38) // round(0.6*0 + 0.4*95)
40+
})
41+
})
42+
43+
describe('submitAwardTool', () => {
44+
it('echoes the submitted input', async () => {
45+
expect(await submitAwardTool.execute({ by: 'seller-good', reason: 'best value' }))
46+
.toEqual({ by: 'seller-good', reason: 'best value' })
47+
})
48+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Tools for the buyer's bounded award-selection loop (see award.ts's pickWinner). Reputation is
3+
* fetched once before the loop starts and closed over here, so compute_value_score stays a pure
4+
* function of its input rather than doing a live fetch per tool call.
5+
*/
6+
import type { SellerReputation, Tool } from '@pay/agent-runtime'
7+
8+
export interface FetchSellerReputationOutput {
9+
available: boolean
10+
reputation: SellerReputation[]
11+
}
12+
13+
export function fetchSellerReputationTool(reputation: SellerReputation[]): Tool<Record<string, never>, FetchSellerReputationOutput> {
14+
return {
15+
name: 'fetch_seller_reputation',
16+
description: "Fetch each bidding seller's track record from the run ledger (score, wins, verification history).",
17+
async execute() {
18+
return { available: reputation.length > 0, reputation }
19+
},
20+
}
21+
}
22+
23+
export interface ComputeValueScoreInput {
24+
by: string
25+
priceSol: number
26+
}
27+
28+
export interface ComputeValueScoreOutput {
29+
by: string
30+
priceSol: number
31+
valueScore: number
32+
repScore: number
33+
withinBudget: boolean
34+
}
35+
36+
/** No history yet - don't penalize a newcomer to zero. */
37+
const NEUTRAL_REP_SCORE = 50
38+
39+
export function computeValueScoreTool(budgetSol: number, reputation: SellerReputation[]): Tool<ComputeValueScoreInput, ComputeValueScoreOutput> {
40+
return {
41+
name: 'compute_value_score',
42+
description: 'Given a seller name and its bid price, returns a deterministic 0-100 value score (price weighed against track record). Call once per bid before deciding.',
43+
async execute(input) {
44+
const rep = reputation.find((r) => r.seller === input.by)
45+
const repScore = rep?.score ?? NEUTRAL_REP_SCORE
46+
const withinBudget = input.priceSol <= budgetSol
47+
const priceScore = withinBudget ? 100 - Math.min(1, input.priceSol / budgetSol) * 100 : 0
48+
const valueScore = Math.round(0.6 * priceScore + 0.4 * repScore)
49+
return { by: input.by, priceSol: input.priceSol, valueScore, repScore, withinBudget }
50+
},
51+
}
52+
}
53+
54+
export interface SubmitAwardInput {
55+
by: string
56+
reason: string
57+
}
58+
59+
/** Forced final tool - the loop terminates only when the model calls this. */
60+
export const submitAwardTool: Tool<SubmitAwardInput, SubmitAwardInput> = {
61+
name: 'submit_award',
62+
description: 'Submit the final award decision: {by, reason}. Ends the loop.',
63+
async execute(input) {
64+
return input
65+
},
66+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect } from 'vitest'
2+
import type { Bid, Want } from '@pay/agent-runtime'
3+
import { pickWinner } from './award.js'
4+
5+
const want: Want = { round: 1, service: 'txline', arg: '123', budgetSol: 0.001 }
6+
const pool: Bid[] = [
7+
{ round: 1, priceSol: 0.0005, by: 'seller-a' },
8+
{ round: 1, priceSol: 0.0007, by: 'seller-b' },
9+
]
10+
11+
const scripted = (...replies: string[]) => {
12+
let call = 0
13+
return async () => replies[call++]
14+
}
15+
const submitAward = (by: string, reason: string) => JSON.stringify({ tool: 'submit_award', input: { by, reason } })
16+
const respond = (status: number, body?: unknown) =>
17+
(async () => ({ status, ok: status >= 200 && status < 300, json: async () => body })) as unknown as typeof fetch
18+
19+
describe('pickWinner', () => {
20+
it('skips the model for a single bid', async () => {
21+
const d = await pickWinner(want, [pool[0]], 'buyer-x')
22+
expect(d.winner).toBe(pool[0])
23+
expect(d.llm).toMatchObject({ status: 'skipped' })
24+
})
25+
26+
it('honours the model award via the tool loop', async () => {
27+
const d = await pickWinner(want, pool, 'buyer-x', undefined, scripted(submitAward('seller-b', 'better track record')))
28+
expect(d.winner.by).toBe('seller-b')
29+
expect(d.reason).toBe('better track record')
30+
expect(d.llm).toMatchObject({ status: 'used' })
31+
})
32+
33+
it('falls back to cheapest when the LLM errors', async () => {
34+
const d = await pickWinner(want, pool, 'buyer-x', undefined, async () => { throw new Error('llm down') })
35+
expect(d.winner.by).toBe('seller-a')
36+
expect(d.llm).toMatchObject({ status: 'fallback', reason: 'LLM unavailable: llm down' })
37+
})
38+
39+
it('falls back to cheapest when the loop exhausts its rounds', async () => {
40+
const d = await pickWinner(
41+
want, pool, 'buyer-x', undefined,
42+
async () => JSON.stringify({ tool: 'fetch_seller_reputation', input: {} }),
43+
)
44+
expect(d.winner.by).toBe('seller-a')
45+
expect(d.llm).toMatchObject({ status: 'fallback', reason: 'model exhausted rounds without deciding' })
46+
})
47+
48+
it('falls back to cheapest when the model picks a seller outside the pool', async () => {
49+
const d = await pickWinner(want, pool, 'buyer-x', undefined, scripted(submitAward('seller-ghost', 'made up')))
50+
expect(d.winner.by).toBe('seller-a')
51+
expect(d.llm).toMatchObject({ status: 'fallback', reason: 'model returned a seller outside the bid pool' })
52+
})
53+
54+
it('fetches reputation once and folds it into compute_value_score', async () => {
55+
const replies = [
56+
JSON.stringify({ tool: 'fetch_seller_reputation', input: {} }),
57+
JSON.stringify({ tool: 'compute_value_score', input: { by: 'seller-a', priceSol: 0.0005 } }),
58+
submitAward('seller-a', 'best value'),
59+
]
60+
let call = 0
61+
const d = await pickWinner(
62+
want, pool, 'buyer-x', 'http://x/api/reputation',
63+
async () => replies[call++],
64+
respond(200, { reputation: [{ seller: 'seller-a', awarded: 3, delivered: 3, settled: 3, verifiedPass: 3, verifiedFail: 0, refunded: 0, score: 90 }] }),
65+
)
66+
expect(d.winner.by).toBe('seller-a')
67+
expect(d.reason).toBe('best value')
68+
})
69+
})
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Buyer award selection - a bounded tool-calling loop for picking the best-value bid (see
3+
* packages/harness-runtime/src/quote.ts's decideBid for the identical pattern this mirrors).
4+
* Reputation (when a reputation URL is configured) is fetched once and folded into a deterministic
5+
* value score via compute_value_score; the model proposes through fetch_seller_reputation /
6+
* compute_value_score / submit_award, and a deterministic fallback (cheapest bid) covers any
7+
* failure - a bad model call costs a worse pick, never a stuck round.
8+
*/
9+
import {
10+
complete, sha256Hex, llmRuntimeInfo, runToolLoop, BudgetGuard, StepCounter, pickCheapest,
11+
type Bid, type Want, type LlmUse, type CompleteOpts,
12+
} from '@pay/agent-runtime'
13+
import { fetchReputation } from './reputation.js'
14+
import { fetchSellerReputationTool, computeValueScoreTool, submitAwardTool, type SubmitAwardInput } from './award-tools.js'
15+
16+
type Llm = (opts: CompleteOpts) => Promise<string>
17+
const selectionGuardrail = 'winner must match collected BID set; fallback is cheapest available bid'
18+
19+
function buyerLlm(
20+
round: number,
21+
agent: string,
22+
status: LlmUse['status'],
23+
reason: string,
24+
audit: Pick<LlmUse, 'inputHash' | 'outputHash'> = {},
25+
): LlmUse {
26+
const info = status === 'skipped' ? undefined : llmRuntimeInfo({ maxTokens: 150 })
27+
return {
28+
round,
29+
agent,
30+
purpose: 'buyer_award',
31+
status,
32+
...(info ? { provider: info.provider, model: info.model } : {}),
33+
usedFor: 'buyer_award',
34+
affectedFunds: false,
35+
...audit,
36+
reason,
37+
guardrail: selectionGuardrail,
38+
createdAt: new Date().toISOString(),
39+
}
40+
}
41+
42+
const errReason = (e: unknown): string => String((e as Error).message ?? e).slice(0, 120)
43+
44+
/** Best-value selection via a bounded tool loop; deterministic cheapest fallback on any failure. */
45+
export async function pickWinner(
46+
want: Want,
47+
pool: Bid[],
48+
buyerName: string,
49+
reputationUrl?: string,
50+
llm: Llm = complete,
51+
doFetch: typeof fetch = fetch,
52+
): Promise<{ winner: Bid; reason?: string; llm: LlmUse }> {
53+
if (pool.length === 1) {
54+
return { winner: pool[0], llm: buyerLlm(want.round, buyerName, 'skipped', 'single bid; no model selection needed') }
55+
}
56+
57+
const reputation = (reputationUrl && (await fetchReputation(reputationUrl, doFetch))) || []
58+
59+
const system =
60+
'You are a buyer choosing the best-value bid for a Solana data service. Call fetch_seller_reputation ' +
61+
'once to see track records, then compute_value_score for each bid before deciding - a cheap seller ' +
62+
'that fails verification or no-shows is not a bargain. Call submit_award with {"by": "<seller name>", ' +
63+
'"reason": "<short>"}.'
64+
const initialPrompt =
65+
`service=${want.service} arg=${want.arg} budget=${want.budgetSol}\nbids:\n` +
66+
pool.map((b) => `- ${b.by}: ${b.priceSol} SOL${b.note ? ` (${b.note})` : ''}`).join('\n')
67+
const inputHash = sha256Hex(`${system}\n${initialPrompt}`)
68+
69+
let finalInput: SubmitAwardInput | undefined
70+
let outputHash: string | undefined
71+
try {
72+
const outcome = await runToolLoop(
73+
{
74+
agentId: buyerName,
75+
system,
76+
initialPrompt,
77+
tools: [
78+
fetchSellerReputationTool(reputation),
79+
computeValueScoreTool(want.budgetSol, reputation),
80+
submitAwardTool,
81+
],
82+
finalToolName: 'submit_award',
83+
maxRounds: 5,
84+
// No lamports move during award selection — only escrow deposit does — so the spend cap is
85+
// never meant to bind here; see the matching note in quote.ts's decideBid.
86+
budget: new BudgetGuard({ maxToolCalls: 10, maxSpendLamports: Number.MAX_SAFE_INTEGER, maxDurationSecs: 30 }),
87+
steps: new StepCounter(5),
88+
maxTokens: 150,
89+
},
90+
llm,
91+
)
92+
finalInput = outcome.finalInput as SubmitAwardInput | undefined
93+
if (finalInput) outputHash = sha256Hex(JSON.stringify(finalInput))
94+
} catch (e) {
95+
return {
96+
winner: pickCheapest(pool)!,
97+
reason: 'cheapest available',
98+
llm: buyerLlm(want.round, buyerName, 'fallback', `LLM unavailable: ${errReason(e)}`, { inputHash }),
99+
}
100+
}
101+
102+
if (!finalInput) {
103+
return {
104+
winner: pickCheapest(pool)!,
105+
reason: 'cheapest available',
106+
llm: buyerLlm(want.round, buyerName, 'fallback', 'model exhausted rounds without deciding', { inputHash }),
107+
}
108+
}
109+
110+
const chosen = pool.find((b) => b.by === finalInput!.by)
111+
if (!chosen) {
112+
return {
113+
winner: pickCheapest(pool)!,
114+
reason: 'cheapest available',
115+
llm: buyerLlm(want.round, buyerName, 'fallback', 'model returned a seller outside the bid pool', { inputHash, outputHash }),
116+
}
117+
}
118+
119+
console.error(`[buyer] picked ${chosen.by} (${chosen.priceSol} SOL): ${finalInput.reason ?? ''}`)
120+
return {
121+
winner: chosen,
122+
reason: finalInput.reason,
123+
llm: buyerLlm(want.round, buyerName, 'used', finalInput.reason ?? 'model selected winner via tool loop', { inputHash, outputHash }),
124+
}
125+
}

0 commit comments

Comments
 (0)