-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathseller.ts
More file actions
295 lines (266 loc) · 10.8 KB
/
Copy pathseller.ts
File metadata and controls
295 lines (266 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import { base } from "@account-kit/infra";
import dotenv from "dotenv";
import {
AcpAgent,
AssetToken,
PrivyAlchemyEvmProviderAdapter,
type AcpAgentOffering,
type JobRoomEntry,
type JobSession,
} from "../../index.js";
dotenv.config({ quiet: true });
// ---------------------------------------------------------------------------
// Seller lifecycle (state machine driven by `entry` events):
//
// job.created → wait for buyer's requirement message
// message(contentType=...) → call session.setBudget() (or session.reject())
// budget.set → wait for buyer to fund
// job.funded → deliver via session.submit() (or session.reject())
// job.completed → done; session.toContext() has the transcript
// job.rejected → log reason; session is terminal
// job.expired → log; session is terminal (deadline passed)
//
// Reject points (see inline ▸ markers):
// • requirement message — refuse if the request is out of capability
// (unknown offering, malformed requirement, …)
// • job.funded — refuse if you can no longer deliver
// (rare; capability concerns usually surface earlier)
//
// Required env vars (see .env.example):
// SELLER_WALLET_ADDRESS, SELLER_WALLET_ID, SELLER_SIGNER_PRIVATE_KEY
// ---------------------------------------------------------------------------
const shortAddr = (a: string): string =>
!a || !a.startsWith("0x") || a.length < 12
? a
: `${a.slice(0, 6)}…${a.slice(-4)}`;
// `session.job` is populated by the SDK before the entry handler fires (both
// at hydration time and on live dispatch), so we can read the canonical role
// addresses straight off the loaded job rather than scanning history.
const counterpartyRole = (session: JobSession, addr: string): string => {
const job = session.job;
if (!job) return "peer";
const a = addr.toLowerCase();
if (job.clientAddress.toLowerCase() === a) return "client";
if (job.providerAddress.toLowerCase() === a) return "provider";
if (job.evaluatorAddress.toLowerCase() === a) return "evaluator";
return "peer";
};
const log = {
info: (m: string) => console.log(`[seller] ${m}`),
job: (id: string | number, m: string) =>
console.log(`[seller] [job ${id}] ${m}`),
chat: (session: JobSession, from: string, content: string) =>
console.log(
`[seller] [job ${session.jobId}] ${counterpartyRole(
session,
from
)} ${shortAddr(from)}: ${content}`
),
send: (session: JobSession, content: string) =>
console.log(`[seller] [job ${session.jobId}] me: ${content}`),
warn: (m: string) => console.warn(`[seller] [warn] ${m}`),
error: (m: string, e?: unknown) =>
console.error(`[seller] [error] ${m}`, e ?? ""),
};
function formatRequirement(r: unknown): string {
return typeof r === "string" ? r : JSON.stringify(r);
}
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
async function main(): Promise<void> {
const seller = await AcpAgent.create({
provider: await PrivyAlchemyEvmProviderAdapter.create({
walletAddress: requireEnv("SELLER_WALLET_ADDRESS") as `0x${string}`,
walletId: requireEnv("SELLER_WALLET_ID"),
signerPrivateKey: requireEnv("SELLER_SIGNER_PRIVATE_KEY"),
chains: [base],
}),
});
const sellerAddress = (await seller.getAddress()).toLowerCase();
log.info(`address: ${sellerAddress}`);
// Fetch our own registry record once at startup and index offerings by name.
// The buyer's "requirement" message carries `name: offering.name`, so we can
// look up the price set on the registry instead of hardcoding it.
//
// Tradeoff: this snapshots the price at startup. If you frequently update
// offering prices on the registry and want the seller to pick them up
// without a restart, move the lookup inline (call `getAgentByWalletAddress`
// each time a requirement arrives).
let offeringsByName = new Map<string, AcpAgentOffering>();
try {
const me = await seller.getAgentByWalletAddress(sellerAddress);
offeringsByName = new Map(
(me?.offerings ?? []).map((o) => [o.name, o] as const)
);
log.info(`loaded ${offeringsByName.size} offering(s):`);
for (const o of offeringsByName.values()) {
log.info(
` - ${o.name}: ${o.priceValue} USDC (priceType=${o.priceType}, sla=${o.slaMinutes}min)`
);
}
} catch (err) {
log.warn(
`failed to load registry offerings; will use fallback budget: ${err}`
);
}
seller.on("entry", async (session: JobSession, entry: JobRoomEntry) => {
if (
entry.kind === "message" &&
entry.from.toLowerCase() !== sellerAddress &&
entry.contentType !== "requirement"
) {
log.chat(session, entry.from, entry.content);
}
if (entry.kind === "system") {
switch (entry.event.type) {
case "job.created":
log.job(
session.jobId,
`new job received from buyer ${shortAddr(entry.event.client)}`
);
break;
case "job.funded":
log.job(session.jobId, "funded, delivering");
// ▸ Reject point #2 — late capability check.
// You usually catch capability issues at the requirement stage
// (below). But if an external dependency you rely on has gone
// down between budget.set and job.funded, you can still reject
// here — funds are returned to the buyer.
//
// if (!await canDeliver()) {
// await session.sendMessage("Upstream service unavailable: <details>");
// await session.reject("upstream down");
// return;
// }
try {
log.send(session, "Got the funds. Working on it now.");
await session.sendMessage("Got the funds. Working on it now.");
await session.submit("Test deliverable");
log.job(session.jobId, "submitted deliverable");
} catch (err) {
log.error(`delivery failed on job ${session.jobId}`, err);
}
break;
case "job.completed":
log.job(session.jobId, "completed");
log.info("---- transcript ----");
console.log(await session.toContext());
log.info("---- end transcript ----");
break;
case "job.rejected": {
// The seller stays running to accept more jobs — no `seller.stop()`
// here. The session is terminal; later entries on it will be ignored
// by the `status === "open"` guard below.
const role = counterpartyRole(session, entry.event.rejector);
log.job(
session.jobId,
`rejected by ${role} ${shortAddr(entry.event.rejector)}: ${entry.event.reason}`
);
break;
}
case "job.expired":
// Like rejection, this is terminal but not actionable from the
// seller side. Log and keep listening for new jobs.
log.job(session.jobId, "expired");
break;
}
}
// The buyer's first message carries the structured requirement.
// Guard on `status === "open"` for idempotency: once we've set a budget the
// status advances to "budget_set", so replayed/duplicate entries are ignored.
if (
entry.kind === "message" &&
entry.contentType === "requirement" &&
session.status === "open"
) {
// ▸ Reject point #1 — capability check.
// This is the natural place to refuse a job: the requirement just
// arrived, we haven't committed to anything on-chain, and the buyer
// hasn't funded yet. Reject if:
// • the on-chain offering name (job description) is missing or
// not one we serve
// • the requirement payload doesn't parse
// • the request itself is out of scope for this seller
//
// We send the full diagnostic over chat with `sendMessage` first, then
// call `reject` with a short tag — so the buyer's `job.rejected`
// handler gets a clean tag *and* the chat history has the detail.
const rejectWithDetail = async (
tag: string,
detail: string
): Promise<void> => {
log.job(session.jobId, `rejecting — ${detail} (tag: "${tag}")`);
await session.sendMessage(detail);
await session.reject(tag);
};
// The offering name is set on-chain in the job's `description` field
// by `createJobFromOffering`. We trust the on-chain value for routing
// rather than expecting the message envelope to repeat it.
const offeringName = session.job?.description;
if (!offeringName) {
await rejectWithDetail(
"missing offering name",
"Job description is empty; cannot identify offering"
);
return;
}
let requirementData: unknown;
try {
requirementData = JSON.parse(entry.content);
} catch (err) {
await rejectWithDetail(
"unparseable requirement",
`Could not parse requirement payload: ${err}`
);
return;
}
log.job(
session.jobId,
`received requirement for "${offeringName}": ${formatRequirement(
requirementData
)}`
);
// Resolve the budget from the offering registered on the registry.
// `priceValue` is treated as USDC here; if your offering's `priceType`
// refers to a different asset, swap `AssetToken.usdc` accordingly.
const offering = offeringsByName.get(offeringName);
if (!offering) {
await rejectWithDetail(
"unsupported offering",
`Offering "${offeringName}" is not supported by this seller`
);
return;
}
log.job(
session.jobId,
`matched offering "${offering.name}" (${offering.priceValue} USDC, sla=${offering.slaMinutes}min)`
);
// Add domain-specific capability checks here, e.g.:
// if (!canHandle(requirementData)) {
// await session.reject("Request is out of capability");
// return;
// }
try {
await session.setBudget(
AssetToken.usdc(offering.priceValue, session.chainId)
);
log.job(session.jobId, `set budget to ${offering.priceValue} USDC`);
} catch (err) {
log.error(`setBudget failed on job ${session.jobId}`, err);
}
}
});
await seller.start();
log.info("ready, listening for jobs");
const shutdown = async (signal: NodeJS.Signals) => {
log.info(`received ${signal}, shutting down`);
await seller.stop();
process.exit(0);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
}
main().catch(console.error);