Skip to content

Commit c4c673a

Browse files
trilltinoclaude
andcommitted
feat(swarm): live broker swarm — money through a graph of agents (3rd demo tab)
A working multi-agent swarm, verified end-to-end on devnet: a buyer asks a BROKER, the broker shops two sellers (cheap 0.0001 / premium 0.0003), buys from the cheaper ON-CHAIN, and resells to the buyer at +20% ON-CHAIN — two settlements per request, the broker keeps the spread. All over CoralOS, reusing the existing agents/runtime. - runtime: add ctx.waitForMentionInThread(threadId) so an agent can juggle many threads (the broker correlates each seller's quote by thread). - broker agent (coral-agents/broker/): the coordinator — both buyer (pays sellers via a buyer-style wallet.ts) and seller (charges the buyer via the seller's payment.ts, dynamic price). Crash-proofed loop. - two priced sellers (coral-agents/seller-{cheap,premium}/): thin manifests that REUSE the seller-agent:0.1.0 image — no code duplication. (CoralOS requires a `readme` field; the live folder-rescan is unreliable on Windows volumes, so a coral restart re-scans.) - buyer-agent: configurable TARGET_AGENT / BUYER_REQUEST / wait timeouts (backward-compatible defaults) so it can transact with a broker instead of a seller directly. - bridge: /swarm/start + /swarm/feed (session of buyer+broker+2 sellers); compose passes the swarm wallets. - web: a third "Swarm" tab with a live 4-agent feed (SwarmTab + SwarmFeed). - scripts/provision-swarm.js: creates + funds a broker wallet and two rent-exempt seller wallets via a devnet transfer (the faucet is manual). - docs/SWARM.md updated from blueprint to shipped. Verified: broker bought @ 0.0001 (sig fz8aNU5a…), resold @ 0.00012 (sig 3tw9ZXCF…), delivered to the buyer — live via the bridge /swarm endpoints too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3e9710f commit c4c673a

24 files changed

Lines changed: 669 additions & 29 deletions

File tree

coral-agents/broker/Dockerfile

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Build from repo root:
2+
# docker build -f coral-agents/broker/Dockerfile -t broker:0.1.0 .
3+
4+
FROM node:20-alpine AS builder
5+
WORKDIR /app
6+
7+
# Mirror repo layout so "file:../../packages/agent-runtime" resolves correctly
8+
COPY packages/agent-runtime/package.json packages/agent-runtime/package-lock.json* ./packages/agent-runtime/
9+
RUN cd packages/agent-runtime && npm install --ignore-scripts
10+
COPY packages/agent-runtime/tsconfig.json ./packages/agent-runtime/
11+
COPY packages/agent-runtime/src/ ./packages/agent-runtime/src/
12+
RUN cd packages/agent-runtime && npx tsc
13+
14+
# --install-links copies the runtime into node_modules instead of symlinking
15+
COPY coral-agents/broker/package.json coral-agents/broker/package-lock.json* ./coral-agents/broker/
16+
RUN cd coral-agents/broker && npm install --ignore-scripts --install-links
17+
COPY coral-agents/broker/tsconfig.json ./coral-agents/broker/
18+
COPY coral-agents/broker/src/ ./coral-agents/broker/src/
19+
RUN cd coral-agents/broker && npx tsc
20+
21+
FROM node:20-alpine
22+
WORKDIR /app
23+
COPY --from=builder /app/coral-agents/broker/dist ./dist
24+
COPY --from=builder /app/coral-agents/broker/node_modules ./node_modules
25+
26+
CMD ["node", "dist/index.js"]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
edition = 4
2+
3+
[agent]
4+
name = "broker"
5+
version = "0.1.0"
6+
description = "Swarm broker — routes a buyer's request to the cheapest of several sellers and resells at a markup."
7+
summary = "An agent that is both buyer and seller: shops sellers, buys the cheapest, resells to the buyer at a margin."
8+
readme = "Receives 'request <service>' from a buyer, quotes every seller in SWARM_SELLERS (each in its own thread), buys from the cheapest (paying on-chain), then resells to the buyer at +MARKUP (charging on-chain). Two settlements per request; the broker keeps the spread."
9+
10+
[agent.license]
11+
type = "spdx"
12+
expression = "MIT"
13+
14+
[options]
15+
BROKER_KEYPAIR_B58 = { type = "string", description = "Devnet keypair the broker pays sellers with (its pubkey also receives the buyer's payment)" }
16+
SELLER_WALLET = { type = "string", description = "Broker's receive wallet — its own pubkey" }
17+
SWARM_SELLERS = { type = "string", default = "seller-cheap,seller-premium", description = "CSV of seller agent names to shop" }
18+
MARKUP = { type = "f64", default = 1.2, description = "Resale markup multiplier" }
19+
BROKER_MAX_SOL = { type = "f64", default = 0.01, description = "Max SOL the broker will pay a seller" }
20+
SOLANA_RPC_URL = { type = "string", default = "", description = "Solana RPC URL" }
21+
22+
[runtimes.docker]
23+
image = "broker:0.1.0"

coral-agents/broker/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "broker",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"scripts": {
6+
"build": "tsc",
7+
"start": "node dist/index.js",
8+
"typecheck": "tsc --noEmit"
9+
},
10+
"dependencies": {
11+
"@pay/agent-runtime": "file:../../packages/agent-runtime",
12+
"@solana/web3.js": "^1.98.0",
13+
"@solana/pay": "^0.2.5",
14+
"bignumber.js": "^9.1.2"
15+
},
16+
"devDependencies": {
17+
"typescript": "^5.4.0",
18+
"@types/node": "^20.0.0"
19+
}
20+
}

coral-agents/broker/src/index.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Broker agent — the swarm coordinator. It is BOTH a buyer and a seller:
3+
*
4+
* buyer ──"request <service>"──▶ broker
5+
* broker quotes every seller in SWARM_SELLERS (each in its own thread)
6+
* broker buys from the cheapest (broker PAYS the seller on-chain)
7+
* broker resells to the buyer at +MARKUP (buyer PAYS the broker on-chain)
8+
*
9+
* Two on-chain settlements per request; the broker keeps the spread. Reuses the kit's
10+
* seller payment.ts (to charge the buyer) and a buyer-style wallet.ts (to pay sellers).
11+
*
12+
* Env: BROKER_KEYPAIR_B58 (pays sellers), SELLER_WALLET (= broker pubkey, receives from buyer),
13+
* SWARM_SELLERS (csv), MARKUP, BROKER_MAX_SOL, SOLANA_RPC_URL.
14+
*/
15+
import { startCoralAgent } from '@pay/agent-runtime'
16+
import { generatePaymentUrl, verifyPayment } from './payment.js'
17+
import { payFromUrl, getBrokerPublicKey } from './wallet.js'
18+
19+
const SELLERS = (process.env.SWARM_SELLERS || 'seller-cheap,seller-premium')
20+
.split(',').map((s) => s.trim()).filter(Boolean)
21+
const MARKUP = parseFloat(process.env.MARKUP ?? '1.2')
22+
const MAX_SOL = parseFloat(process.env.BROKER_MAX_SOL ?? '0.01')
23+
24+
interface Quote { seller: string; thread: string; amount: number; reference: string; url: string }
25+
26+
function parsePaymentRequired(text: string): Omit<Quote, 'seller' | 'thread'> | null {
27+
const amount = parseFloat(text.match(/amount=([\d.]+)/)?.[1] ?? '')
28+
const reference = text.match(/reference=([1-9A-HJ-NP-Za-km-z]{32,44})/)?.[1]
29+
const url = text.match(/url=(solana:[^\s"\\]+)/)?.[1]
30+
if (!amount || !reference || !url) return null
31+
return { amount, reference, url }
32+
}
33+
34+
await startCoralAgent({ agentName: 'broker' }, async (ctx) => {
35+
console.error(`[broker] wallet ${getBrokerPublicKey()} | sellers=${SELLERS.join(',')} | markup=${MARKUP}`)
36+
37+
while (true) {
38+
try {
39+
const ask = await ctx.waitForMention(30_000)
40+
if (!ask || !/^request/i.test(ask.text.trim())) continue
41+
const buyerThread = ask.threadId
42+
const buyer = ask.sender ? [ask.sender] : []
43+
const service = ask.text.trim().replace(/^request\s*/i, '').trim() || 'jupiter'
44+
console.error(`[broker] buyer wants "${service}" — shopping ${SELLERS.length} sellers`)
45+
46+
// 1. Quote every seller (each in its own thread).
47+
const quotes: Quote[] = []
48+
for (const seller of SELLERS) {
49+
try {
50+
const thread = await ctx.createThread(`broker-${seller}-${Date.now()}`, [seller])
51+
await ctx.send(`request ${service}`, thread, [seller])
52+
const reply = await ctx.waitForMentionInThread(thread, 45_000)
53+
const q = reply && parsePaymentRequired(reply.text)
54+
if (q) {
55+
quotes.push({ seller, thread, ...q })
56+
console.error(`[broker] ${seller}: ${q.amount} SOL`)
57+
} else {
58+
console.error(`[broker] ${seller}: no quote`)
59+
}
60+
} catch (e) {
61+
console.error(`[broker] ${seller} error: ${e}`)
62+
}
63+
}
64+
if (quotes.length === 0) {
65+
if (buyerThread) await ctx.send('NO_SELLERS_AVAILABLE', buyerThread, buyer)
66+
continue
67+
}
68+
69+
// 2. Buy from the cheapest seller (broker PAYS on-chain).
70+
quotes.sort((a, b) => a.amount - b.amount)
71+
const best = quotes[0]
72+
console.error(`[broker] cheapest = ${best.seller} @ ${best.amount} SOL — buying`)
73+
let data: string
74+
try {
75+
const sig = await payFromUrl(best.url, MAX_SOL)
76+
await ctx.send(`paid ${sig} reference=${best.reference}`, best.thread, [best.seller])
77+
const delivered = await ctx.waitForMentionInThread(best.thread, 45_000)
78+
if (!delivered || !/DELIVERED/i.test(delivered.text)) throw new Error('seller did not deliver')
79+
data = delivered.text.replace(/^[\s\S]*?DELIVERED\s*/i, '').trim()
80+
} catch (e) {
81+
console.error(`[broker] upstream buy failed: ${e}`)
82+
if (buyerThread) await ctx.send('UPSTREAM_FAILED', buyerThread, buyer)
83+
continue
84+
}
85+
86+
// 3. Resell to the buyer at a markup (buyer PAYS the broker).
87+
const price = +(best.amount * MARKUP).toFixed(6)
88+
const charge = generatePaymentUrl(service, price)
89+
console.error(`[broker] reselling @ ${price} SOL (cost ${best.amount} from ${best.seller}, margin ${(price - best.amount).toFixed(6)})`)
90+
await ctx.reply(ask, `PAYMENT_REQUIRED reference=${charge.reference} amount=${charge.amountSol} url=${charge.url} via=${best.seller}`)
91+
92+
if (!buyerThread) continue
93+
const proof = await ctx.waitForMentionInThread(buyerThread, 120_000)
94+
const buyerSig = proof?.text.match(/paid\s+(\S+)/)?.[1]
95+
if (proof && buyerSig && (await verifyPayment(buyerSig, charge.reference, charge.amountSol))) {
96+
await ctx.reply(proof, `DELIVERED ${data}`)
97+
console.error('[broker] delivered to buyer ✓')
98+
} else {
99+
console.error('[broker] buyer payment not verified')
100+
await ctx.reply(ask, 'PAYMENT_NOT_VERIFIED')
101+
}
102+
} catch (e) {
103+
console.error(`[broker] cycle error: ${e}`)
104+
await new Promise((r) => setTimeout(r, 2000))
105+
}
106+
}
107+
})

coral-agents/broker/src/payment.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Connection, PublicKey, Keypair } from '@solana/web3.js'
2+
import { encodeURL, validateTransfer } from '@solana/pay'
3+
import BigNumber from 'bignumber.js'
4+
5+
const connection = () =>
6+
new Connection(process.env.SOLANA_RPC_URL ?? 'https://api.devnet.solana.com')
7+
8+
/**
9+
* The broker is a SELLER to the buyer: it charges `amountSol` (its cost + markup) to its own wallet
10+
* (`SELLER_WALLET`), tagged with a unique reference. Mirrors seller-agent/payment.ts, but the amount
11+
* is dynamic (per-order) instead of a fixed PRICE_SOL.
12+
*/
13+
export function generatePaymentUrl(request: string, amountSol: number) {
14+
const recipient = process.env.SELLER_WALLET // the broker's receive wallet
15+
if (!recipient) throw new Error('SELLER_WALLET (broker receive wallet) not set')
16+
const reference = Keypair.generate().publicKey
17+
18+
const url = encodeURL({
19+
recipient: new PublicKey(recipient),
20+
amount: new BigNumber(amountSol),
21+
reference,
22+
label: 'Broker',
23+
message: request.slice(0, 100),
24+
})
25+
26+
return { url: url.toString(), reference: reference.toBase58(), amountSol }
27+
}
28+
29+
/** Verify the buyer paid `amountSol` to the broker wallet, carrying `reference`. */
30+
export async function verifyPayment(sig: string, reference: string, amountSol: number): Promise<boolean> {
31+
try {
32+
await validateTransfer(
33+
connection(),
34+
sig,
35+
{
36+
recipient: new PublicKey(process.env.SELLER_WALLET!),
37+
amount: new BigNumber(amountSol),
38+
reference: new PublicKey(reference),
39+
},
40+
{ commitment: 'confirmed' },
41+
)
42+
return true
43+
} catch (e) {
44+
console.error(`[broker] payment verify failed: ${e}`)
45+
return false
46+
}
47+
}

coral-agents/broker/src/wallet.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
Keypair,
3+
Connection,
4+
Transaction,
5+
SystemProgram,
6+
PublicKey,
7+
LAMPORTS_PER_SOL,
8+
sendAndConfirmTransaction,
9+
} from '@solana/web3.js'
10+
11+
/** Load the broker keypair from BROKER_KEYPAIR_B58 (base58 64-byte, devnet-funded). */
12+
function loadKeypair(): Keypair {
13+
const b58 = process.env.BROKER_KEYPAIR_B58
14+
if (!b58) throw new Error('BROKER_KEYPAIR_B58 not set')
15+
// Decode base58 via BigInt — avoids a bs58 dependency (same approach as buyer-agent).
16+
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
17+
let n = BigInt(0)
18+
for (const c of b58) {
19+
const idx = ALPHABET.indexOf(c)
20+
if (idx < 0) throw new Error('Invalid base58 character')
21+
n = n * BigInt(58) + BigInt(idx)
22+
}
23+
const hex = n.toString(16).padStart(128, '0')
24+
const bytes = new Uint8Array(64)
25+
for (let i = 0; i < 64; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
26+
return Keypair.fromSecretKey(bytes)
27+
}
28+
29+
export function getBrokerPublicKey(): string {
30+
return loadKeypair().publicKey.toBase58()
31+
}
32+
33+
/** Pay a seller's Solana Pay URL from the broker wallet, writing the reference key in. */
34+
export async function payFromUrl(solanaPayUrl: string, maxSol: number): Promise<string> {
35+
const raw = solanaPayUrl.replace(/^solana:/, 'solana://')
36+
const url = new URL(raw)
37+
const recipient = new PublicKey(url.hostname || url.pathname.replace(/^\/\//, ''))
38+
const amountSol = parseFloat(url.searchParams.get('amount') ?? '0')
39+
const reference = url.searchParams.get('reference')
40+
41+
if (amountSol <= 0) throw new Error('Invalid amount in Solana Pay URL')
42+
if (amountSol > maxSol) throw new Error(`Amount ${amountSol} SOL exceeds broker budget ${maxSol} SOL`)
43+
44+
const keypair = loadKeypair()
45+
const conn = new Connection(process.env.SOLANA_RPC_URL ?? 'https://api.devnet.solana.com')
46+
47+
const ix = SystemProgram.transfer({
48+
fromPubkey: keypair.publicKey,
49+
toPubkey: recipient,
50+
lamports: Math.round(amountSol * LAMPORTS_PER_SOL),
51+
})
52+
if (reference) {
53+
ix.keys.push({ pubkey: new PublicKey(reference), isSigner: false, isWritable: false })
54+
}
55+
56+
const tx = new Transaction().add(ix)
57+
const sig = await sendAndConfirmTransaction(conn, tx, [keypair], { commitment: 'confirmed' })
58+
console.error(`[broker] paid ${amountSol} SOL → ${recipient.toBase58()} sig=${sig}`)
59+
return sig
60+
}

coral-agents/broker/tsconfig.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "NodeNext",
5+
"moduleResolution": "NodeNext",
6+
"outDir": "./dist",
7+
"strict": true,
8+
"esModuleInterop": true,
9+
"skipLibCheck": true
10+
},
11+
"include": ["src/**/*"]
12+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ ANTHROPIC_API_KEY = { type = "string", default = "", description = "Anthropic AP
1616
BUYER_KEYPAIR_B58 = { type = "string", description = "Base58-encoded devnet keypair — generate: solana-keygen new --no-bip39-passphrase" }
1717
BUYER_MAX_SOL = { type = "f64", default = 0.001, description = "Max SOL to spend per request" }
1818
SOLANA_RPC_URL = { type = "string", default = "", description = "Solana RPC URL" }
19+
TARGET_AGENT = { type = "string", default = "seller-agent", description = "Agent to buy from — a seller directly, or 'broker' in a swarm" }
20+
BUYER_REQUEST = { type = "string", default = "", description = "What to request (e.g. 'jupiter'); empty uses the goal.ts default" }
21+
QUOTE_WAIT_MS = { type = "f64", default = 15000, description = "How long to wait for a payment quote (broker swarms need longer)" }
22+
DELIVERY_WAIT_MS = { type = "f64", default = 30000, description = "How long to wait for delivery after paying" }
23+
CYCLE_INTERVAL_MS = { type = "f64", default = 30000, description = "Delay between purchase cycles" }
1924

2025
[runtimes.docker]
2126
image = "buyer-agent:0.1.0"

coral-agents/buyer-agent/src/goal.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,18 @@ You have a limited SOL budget — only buy if the data seems useful.
1313
After receiving data, summarise what you learned in one sentence.
1414
`
1515

16-
export const BUYER_REQUEST = 'SOL to USDC swap quote'
16+
export const BUYER_REQUEST = process.env.BUYER_REQUEST || 'SOL to USDC swap quote'
17+
18+
// Which agent the buyer transacts with — the seller directly, or a broker in a swarm.
19+
export const TARGET_AGENT = process.env.TARGET_AGENT || 'seller-agent'
1720

1821
// Max SOL to spend per request — never exceed this
1922
export const BUYER_MAX_SOL = parseFloat(process.env.BUYER_MAX_SOL ?? '0.001')
2023

24+
// How long to wait for a payment quote / delivery (ms). A broker takes longer than a direct
25+
// seller (it shops several sellers and pays one first), so the swarm bumps these via env.
26+
export const QUOTE_WAIT_MS = parseInt(process.env.QUOTE_WAIT_MS ?? '15000', 10)
27+
export const DELIVERY_WAIT_MS = parseInt(process.env.DELIVERY_WAIT_MS ?? '30000', 10)
28+
2129
// How long to wait between purchase cycles (ms)
22-
export const CYCLE_INTERVAL_MS = 30_000
30+
export const CYCLE_INTERVAL_MS = parseInt(process.env.CYCLE_INTERVAL_MS ?? '30000', 10)

coral-agents/buyer-agent/src/index.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import Anthropic from '@anthropic-ai/sdk'
1818
import { startCoralAgent } from '@pay/agent-runtime'
1919
import { payFromUrl, getBuyerPublicKey } from './wallet.js'
20-
import { BUYER_GOAL, BUYER_REQUEST, BUYER_MAX_SOL, CYCLE_INTERVAL_MS } from './goal.js'
20+
import { BUYER_GOAL, BUYER_REQUEST, BUYER_MAX_SOL, CYCLE_INTERVAL_MS, TARGET_AGENT, QUOTE_WAIT_MS, DELIVERY_WAIT_MS } from './goal.js'
2121

2222
const llm = process.env.ANTHROPIC_API_KEY ? new Anthropic() : null
2323

@@ -37,19 +37,20 @@ await startCoralAgent({ agentName: 'buyer-agent' }, async (ctx) => {
3737
console.error(`[buyer-agent] budget: ${BUYER_MAX_SOL} SOL per request`)
3838
console.error('[buyer-agent] starting purchase loop')
3939

40-
// Give seller a moment to start up, then create a shared thread
40+
// Give the counterparty a moment to start up, then create a shared thread
41+
console.error(`[buyer-agent] target: ${TARGET_AGENT}`)
4142
await new Promise(r => setTimeout(r, 4_000))
42-
const threadId = await ctx.createThread('buyer-seller-session', ['seller-agent'])
43+
const threadId = await ctx.createThread('buyer-session', [TARGET_AGENT])
4344
console.error(`[buyer-agent] thread created: ${threadId}`)
4445

4546
while (true) {
4647
try {
4748
// ── 1. Request service from seller ──────────────────────────────────
4849
console.error(`[buyer-agent] requesting: ${BUYER_REQUEST}`)
49-
await ctx.send(`request ${BUYER_REQUEST}`, threadId, ['seller-agent'])
50+
await ctx.send(`request ${BUYER_REQUEST}`, threadId, [TARGET_AGENT])
5051

5152
// ── 2. Wait for payment URL ─────────────────────────────────────────
52-
const payMention = await ctx.waitForMention(15_000)
53+
const payMention = await ctx.waitForMention(QUOTE_WAIT_MS)
5354
if (!payMention) {
5455
console.error('[buyer-agent] no response from seller, retrying next cycle')
5556
await new Promise(r => setTimeout(r, CYCLE_INTERVAL_MS))
@@ -87,10 +88,10 @@ await startCoralAgent({ agentName: 'buyer-agent' }, async (ctx) => {
8788
}
8889

8990
// ── 4. Send payment proof to seller ─────────────────────────────────
90-
await ctx.send(`paid ${sig} reference=${reference}`, threadId, ['seller-agent'])
91+
await ctx.send(`paid ${sig} reference=${reference}`, threadId, [TARGET_AGENT])
9192

9293
// ── 5. Wait for data delivery ────────────────────────────────────────
93-
const deliveryMention = await ctx.waitForMention(30_000)
94+
const deliveryMention = await ctx.waitForMention(DELIVERY_WAIT_MS)
9495
if (!deliveryMention?.text.includes('DELIVERED')) {
9596
console.error('[buyer-agent] no delivery received')
9697
await new Promise(r => setTimeout(r, CYCLE_INTERVAL_MS))

0 commit comments

Comments
 (0)