@bnbagent/studio-cli 0.0.6-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/DISCLAIMER.md +48 -0
  2. package/LICENSE +201 -0
  3. package/dist/_agentcoreName-DZDWEYD3.js +7 -0
  4. package/dist/_twak-5XQMOFUC.js +25 -0
  5. package/dist/bag.js +19358 -0
  6. package/dist/chunk-7RAKL4AS.js +172 -0
  7. package/dist/chunk-M3ODFCA7.js +1053 -0
  8. package/dist/chunk-U7IDQ3K5.js +14 -0
  9. package/dist/deployCli-N6TPN6XA.js +40 -0
  10. package/package.json +64 -0
  11. package/recipes/agent/code/{{PKG}}/signing.ts.tmpl +287 -0
  12. package/recipes/agent/recipe.toml +35 -0
  13. package/recipes/providers/pieverse-llm/recipe.toml +16 -0
  14. package/recipes/providers/pieverse-llm/skills/funding-pieverse-llm.md +203 -0
  15. package/recipes/runtimes/agentcore/code/{{PKG}}/.dockerignore.tmpl +8 -0
  16. package/recipes/runtimes/agentcore/code/{{PKG}}/Dockerfile.tmpl +50 -0
  17. package/recipes/runtimes/agentcore/code/{{PKG}}/agentCard.ts.tmpl +135 -0
  18. package/recipes/runtimes/agentcore/code/{{PKG}}/dualMain.ts.tmpl +402 -0
  19. package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +147 -0
  20. package/recipes/runtimes/agentcore/code/{{PKG}}/main.ts.tmpl +344 -0
  21. package/recipes/runtimes/agentcore/code/{{PKG}}/mcpMain.ts.tmpl +677 -0
  22. package/recipes/runtimes/agentcore/code/{{PKG}}/model.ts.tmpl +117 -0
  23. package/recipes/runtimes/agentcore/code/{{PKG}}/sellerCore.ts.tmpl +503 -0
  24. package/recipes/runtimes/agentcore/code/{{PKG}}/tools.ts.tmpl +157 -0
  25. package/recipes/runtimes/agentcore/recipe.toml +97 -0
  26. package/recipes/runtimes/azure-foundry/code/{{PKG}}/.dockerignore.tmpl +8 -0
  27. package/recipes/runtimes/azure-foundry/code/{{PKG}}/Dockerfile.tmpl +47 -0
  28. package/recipes/runtimes/azure-foundry/code/{{PKG}}/agentCard.ts.tmpl +131 -0
  29. package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +504 -0
  30. package/recipes/runtimes/azure-foundry/code/{{PKG}}/foundryMain.ts.tmpl +300 -0
  31. package/recipes/runtimes/azure-foundry/code/{{PKG}}/main.ts.tmpl +196 -0
  32. package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +562 -0
  33. package/recipes/runtimes/azure-foundry/code/{{PKG}}/model.ts.tmpl +117 -0
  34. package/recipes/runtimes/azure-foundry/code/{{PKG}}/tools.ts.tmpl +157 -0
  35. package/recipes/runtimes/azure-foundry/recipe.toml +88 -0
  36. package/recipes/tools-chain/code/{{PKG}}/chainTools.ts.tmpl +166 -0
  37. package/recipes/tools-chain/recipe.toml +11 -0
  38. package/recipes/wallet/recipe.toml +20 -0
  39. package/recipes/x402-buyer/code/{{PKG}}/x402Buyer.ts.tmpl +175 -0
  40. package/recipes/x402-buyer/recipe.toml +15 -0
  41. package/skills/bnbagent-studio.md +107 -0
  42. package/skills/references/bnbagent-studio-adding-to-project.md +241 -0
  43. package/skills/references/bnbagent-studio-buying-from-bazaar.md +169 -0
  44. package/skills/references/bnbagent-studio-buying-via-8183.md +222 -0
  45. package/skills/references/bnbagent-studio-extending-signing.md +227 -0
  46. package/skills/references/bnbagent-studio-operating.md +211 -0
  47. package/skills/references/bnbagent-studio-scaffolding-agent.md +536 -0
  48. package/skills/references/bnbagent-studio-selling-via-8183.md +271 -0
  49. package/skills/references/bnbagent-studio-selling-via-b402.md +194 -0
  50. package/skills/references/bnbagent-studio-use-aws-agentcore.md +208 -0
  51. package/skills/references/bnbagent-studio-use-azure-foundry.md +164 -0
  52. package/skills/references/bnbagent-studio-use-bnb-trial.md +92 -0
  53. package/skills/references/bnbagent-studio-using-altana-wallet.md +68 -0
  54. package/skills/references/bnbagent-studio-using-twak-wallet.md +260 -0
  55. package/skills/references/bnbagent-studio-wiring-llm-tools.md +338 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * A2A executor — the seller agent's outward surface (two fixed-code skills).
3
+ *
4
+ * The agent serves A2A directly through the runtime's A2A entrypoint. For
5
+ * azure-foundry this is the cloud-neutral `@a2a-js/sdk` express app;
6
+ * AgentCore wraps the same protocol on its own runtime contract.
7
+ * {@link SellerAgentExecutor.execute} reads the inbound message's data part
8
+ * and dispatches on its `skill`:
9
+ *
10
+ * negotiate → `signing.signQuote` (rule-based price clamp + EIP-191 sign)
11
+ * notify_funded → `signing.verifySignedJob` (fast on-chain gate) → ACK at
12
+ * once, then in the BACKGROUND: LLM work → `signing.submitResult`
13
+ *
14
+ * `notify_funded` is the buyer's "I funded job X — please deliver"
15
+ * notification. Because the work takes time, the executor does NOT block the
16
+ * caller: it verifies the funded job synchronously (a couple of eth_calls)
17
+ * to ACK accepted/rejected, then runs the slow LLM work + on-chain `submit`
18
+ * in a background task and replies immediately. The buyer reads the
19
+ * deliverable back from the CHAIN (SUBMITTED / `getDeliverableUrl`) — the
20
+ * chain is the source of truth. While any background delivery is in flight
21
+ * {@link SellerAgentExecutor.isBusy} reports busy; AgentCore's entrypoint
22
+ * feeds that to `/ping` as `HEALTHY_BUSY` so the scale-to-zero runtime stays
23
+ * warm until the work lands (within the session max-lifetime).
24
+ *
25
+ * ALL signing is FIXED code in `signing.ts` — NEVER an LLM-callable tool
26
+ * (money is never in the LLM; the LLM only produces the work text, via the
27
+ * `runWork` hook). On each notification the executor also opportunistically
28
+ * sweeps OTHER funded jobs assigned to this provider — the buyer-push
29
+ * fallback for jobs whose buyer funded on-chain but never sent
30
+ * `notify_funded` (deduped against in-flight jobs). Negotiate stays
31
+ * sweep-free so quotes are fast. A periodic poller — which also covers the
32
+ * scale-to-zero cold window when no one is invoking — is the v2 robust path.
33
+ *
34
+ * You own this file — specialise the work hook / dispatch, but keep signing
35
+ * OUT of the LLM tool list.
36
+ */
37
+
38
+ import { randomUUID } from "node:crypto";
39
+ import type { DataPart, Message } from "@a2a-js/sdk";
40
+ import {
41
+ A2AError,
42
+ type AgentExecutor,
43
+ type ExecutionEventBus,
44
+ type RequestContext,
45
+ } from "@a2a-js/sdk/server";
46
+ import { ERC8183JobOps } from "@bnbagent/sdk/erc8183";
47
+ import { SubmitPermanentlyUnsupportedError } from "@bnbagent/studio-runtime/erc8183";
48
+ import { getWallet } from "@bnbagent/studio-runtime/wallet";
49
+ import * as defaultSigning from "./signing.js";
50
+
51
+ const log = {
52
+ info: (msg: string) => console.log(`[seller-agent.a2a] ${msg}`),
53
+ warn: (msg: string) => console.warn(`[seller-agent.a2a] WARNING ${msg}`),
54
+ error: (msg: string, e?: unknown) =>
55
+ console.error(`[seller-agent.a2a] ERROR ${msg}`, e ?? ""),
56
+ };
57
+
58
+ /**
59
+ * The LLM work hook: produce the deliverable text for a prompt. Built in
60
+ * `main.ts` / `foundryMain.ts`; called ONLY inside the background delivery.
61
+ */
62
+ export type RunWork = (
63
+ prompt: string,
64
+ opts: { sessionId: string; abortSignal?: AbortSignal },
65
+ ) => Promise<string>;
66
+
67
+ /** The `signing.ts` surface the executor drives (injectable for tests). */
68
+ export interface SigningApi {
69
+ listPrice(): bigint;
70
+ clampPrice(proposedWei: bigint): bigint;
71
+ signQuote(
72
+ request: Record<string, unknown>,
73
+ clampedPriceWei: bigint,
74
+ ): Promise<Record<string, unknown>>;
75
+ verifySignedJob(
76
+ jobId: number,
77
+ ): Promise<{ ok: boolean; reason: string; permanent: boolean }>;
78
+ jobSpec(
79
+ jobId: number,
80
+ ): Promise<{ task: string; terms: Record<string, unknown> } | null>;
81
+ submitResult(
82
+ jobId: number,
83
+ responseContent: string,
84
+ metadata?: Record<string, unknown> | null,
85
+ ): Promise<{ submitTx: string; deliverableUrl: string | null }>;
86
+ }
87
+
88
+ /** Pending-job scanner used by the sweep (injectable for tests). */
89
+ export type PendingJobsFetcher = (
90
+ network: string,
91
+ ) => Promise<Record<string, unknown>>;
92
+
93
+ const defaultPendingJobs: PendingJobsFetcher = async (network) => {
94
+ const ops = await ERC8183JobOps.create({
95
+ walletProvider: getWallet(),
96
+ network,
97
+ });
98
+ return (await ops.getPendingJobs()) as Record<string, unknown>;
99
+ };
100
+
101
+ export interface SellerAgentExecutorOpts {
102
+ runWork: RunWork;
103
+ generator: string;
104
+ network?: string | null;
105
+ /** Test seam: replace the signing module (default: `./signing.js`). */
106
+ signing?: SigningApi;
107
+ /** Test seam: replace the sweep's pending-job scan. */
108
+ pendingJobs?: PendingJobsFetcher;
109
+ }
110
+
111
+ /**
112
+ * ERC-8183 seller A2A executor: negotiate + notify_funded, backed by
113
+ * signing.ts.
114
+ *
115
+ * `runWork(prompt, { sessionId })` is the LLM work hook (built in `main.ts` /
116
+ * `foundryMain.ts`); it is called inside the background delivery
117
+ * (`notify_funded` → `doWorkAndSubmit`) to produce the deliverable text.
118
+ *
119
+ * The agent exposes ONLY the two paid, structured skills — there is no
120
+ * free-form chat skill. A plain text message (no `{"skill": ...}` DataPart)
121
+ * is rejected: negotiate / notify_funded always need a structured DataPart,
122
+ * so prose never triggers an LLM call or a paid action.
123
+ */
124
+ export class SellerAgentExecutor implements AgentExecutor {
125
+ private readonly runWork: RunWork;
126
+ private readonly generator: string;
127
+ private readonly network: string;
128
+ private readonly signing: SigningApi;
129
+ private readonly pendingJobs: PendingJobsFetcher;
130
+ // Background delivery bookkeeping (see notifyFunded / isBusy):
131
+ // tasks — live background promises (busy-status source).
132
+ // inflight — job ids in flight OR already terminally handled this
133
+ // process (notify/sweep dedup; retained on success so a
134
+ // slower sweep never re-delivers a just-submitted job).
135
+ private readonly tasks = new Set<Promise<void>>();
136
+ private readonly inflight = new Set<number>();
137
+
138
+ constructor(opts: SellerAgentExecutorOpts) {
139
+ this.runWork = opts.runWork;
140
+ this.generator = opts.generator;
141
+ this.network = opts.network ?? "bsc-testnet";
142
+ this.signing = opts.signing ?? defaultSigning;
143
+ this.pendingJobs = opts.pendingJobs ?? defaultPendingJobs;
144
+ }
145
+
146
+ /**
147
+ * True while any background delivery is in flight.
148
+ *
149
+ * AgentCore's entrypoint feeds this to `/ping` (`HEALTHY_BUSY` when busy)
150
+ * so a scale-to-zero runtime is not reaped on idle while work runs. The
151
+ * azure/local uvicorn-style process stays up on its own, so there it is
152
+ * informational only.
153
+ */
154
+ isBusy(): boolean {
155
+ return this.tasks.size > 0;
156
+ }
157
+
158
+ /** Await every in-flight background task (test helper — not on the wire). */
159
+ async drain(): Promise<void> {
160
+ while (this.tasks.size > 0) {
161
+ await Promise.allSettled([...this.tasks]);
162
+ }
163
+ }
164
+
165
+ // ── protocol-agnostic skill dispatch ──────────────────────────────────────
166
+
167
+ /**
168
+ * Run the skill named by `data.skill` and return its result dict.
169
+ *
170
+ * The single source of truth for the seller's behaviour, independent of
171
+ * the wire protocol. The @a2a-js path ({@link execute}) reads a DataPart
172
+ * into `data` and writes the result back as a DataPart; the Azure Foundry
173
+ * Invocations host (`foundryMain.ts`) parses a text-encoded JSON envelope
174
+ * into `data` and serialises the result back to text. Both call HERE so
175
+ * negotiate / notify_funded behave identically on every runtime.
176
+ *
177
+ * Never throws: a skill failure (or an unknown / missing skill) is
178
+ * returned as an `{"error": ...}` dict so the caller can always reply (an
179
+ * escaped exception would hang the A2A caller with no response).
180
+ */
181
+ async dispatch(
182
+ data: Record<string, unknown>,
183
+ ): Promise<Record<string, unknown>> {
184
+ const skill = data.skill;
185
+ try {
186
+ if (skill === "negotiate") {
187
+ return await this.negotiate(data);
188
+ }
189
+ if (skill === "notify_funded") {
190
+ return await this.notifyFunded(data);
191
+ }
192
+ // Includes a plain text message (no skill envelope → skill is
193
+ // undefined): the seller has no free-form skill, so prose is rejected
194
+ // here.
195
+ return {
196
+ error: `unknown skill: ${JSON.stringify(skill)}`,
197
+ skills: this.skills(),
198
+ };
199
+ } catch (e) {
200
+ // a skill failure must still ACK the buyer
201
+ log.error(`skill ${JSON.stringify(skill)} failed`, e);
202
+ const name = e instanceof Error ? e.constructor.name : "Error";
203
+ const msg = e instanceof Error ? e.message : String(e);
204
+ return { error: `${name}: ${msg}`, skill };
205
+ }
206
+ }
207
+
208
+ // ── A2A entrypoints ─────────────────────────────────────────────────────
209
+
210
+ execute = async (
211
+ context: RequestContext,
212
+ eventBus: ExecutionEventBus,
213
+ ): Promise<void> => {
214
+ const result = await this.dispatch(inbound(context));
215
+ reply(eventBus, context, result);
216
+ };
217
+
218
+ cancelTask = async (
219
+ _taskId: string,
220
+ _eventBus: ExecutionEventBus,
221
+ ): Promise<void> => {
222
+ // negotiate is synchronous; notify_funded acks then delivers on-chain in
223
+ // the background — once submitted it is anchored on-chain and cannot be
224
+ // cancelled via A2A. Nothing to cancel here.
225
+ throw A2AError.unsupportedOperation("cancel");
226
+ };
227
+
228
+ // ── skills ────────────────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * Rule-based quote → SDK `NegotiationResult` envelope (no LLM).
232
+ *
233
+ * The price is the FIXED list price from studio.toml, clamped to
234
+ * `[min,max]` BEFORE signing — a misconfigured or hostile request can
235
+ * never sign out of bounds. The buyer parses this envelope verbatim and
236
+ * anchors it on-chain via `createJob` + `fund`.
237
+ */
238
+ private async negotiate(
239
+ data: Record<string, unknown>,
240
+ ): Promise<Record<string, unknown>> {
241
+ let request = data.request;
242
+ if (request === null || typeof request !== "object" || Array.isArray(request)) {
243
+ const picked: Record<string, unknown> = {};
244
+ for (const k of ["task_description", "terms"]) {
245
+ if (k in data) picked[k] = data[k];
246
+ }
247
+ request = picked;
248
+ }
249
+ const clamped = this.signing.clampPrice(this.signing.listPrice());
250
+ return this.signing.signQuote(request as Record<string, unknown>, clamped);
251
+ }
252
+
253
+ /** The seller's two advertised skills. */
254
+ skills(): string[] {
255
+ return ["negotiate", "notify_funded"];
256
+ }
257
+
258
+ /**
259
+ * Buyer notification: "I funded job X — please deliver."
260
+ *
261
+ * Verify the funded job synchronously (a couple of eth_calls) to ACK
262
+ * accepted/rejected at once, then run the slow LLM work + on-chain
263
+ * `submit` in a BACKGROUND task and reply IMMEDIATELY. The buyer reads
264
+ * the deliverable back from the CHAIN (SUBMITTED / `getDeliverableUrl`) —
265
+ * the chain is the source of truth (see erc8183-buyer-push.md).
266
+ *
267
+ * An accepted notification also kicks a background sweep (deduped against
268
+ * in-flight jobs), so a buyer that funded but forgot to notify is still
269
+ * served while we're warm. A rejected / malformed notification spawns
270
+ * nothing.
271
+ */
272
+ private async notifyFunded(
273
+ data: Record<string, unknown>,
274
+ ): Promise<Record<string, unknown>> {
275
+ const raw = data.job_id;
276
+ if (raw === undefined || raw === null || String(raw) === "") {
277
+ this.spawn(() => this.sweep()); // bare notify → just scan stragglers
278
+ return {
279
+ status: "accepted",
280
+ note: "no job_id — scanning funded jobs in the background; poll the chain for results",
281
+ };
282
+ }
283
+ let jobId: number;
284
+ try {
285
+ jobId = parseJobId(raw);
286
+ } catch {
287
+ return { status: "rejected", error: `invalid job_id: ${JSON.stringify(raw)}` };
288
+ }
289
+ let verified = false;
290
+ try {
291
+ const v = await this.signing.verifySignedJob(jobId);
292
+ if (!v.ok && v.permanent) {
293
+ return { status: "rejected", job_id: jobId, reason: v.reason };
294
+ }
295
+ verified = v.ok;
296
+ } catch (e) {
297
+ // pre-verify is best-effort; the background delivery re-verifies
298
+ log.warn(
299
+ `pre-verify of job ${jobId} failed (${e instanceof Error ? e.message : e}); accepting, will re-verify in background`,
300
+ );
301
+ }
302
+ this.spawnJob(jobId, { verified });
303
+ this.spawn(() => this.sweep()); // straggler fallback alongside the named job
304
+ return {
305
+ status: "accepted",
306
+ job_id: jobId,
307
+ note: "delivery started; poll the chain (SUBMITTED / get_deliverable_url) for the result",
308
+ };
309
+ }
310
+
311
+ // ── background delivery ───────────────────────────────────────────────────
312
+
313
+ /** Run `work` as a tracked background task (keeps {@link isBusy} true). */
314
+ private spawn(work: () => Promise<void>): void {
315
+ const task = work().catch((e) => {
316
+ // a background task must never crash the process
317
+ log.error("background task failed", e);
318
+ });
319
+ this.tasks.add(task);
320
+ task.finally(() => this.tasks.delete(task));
321
+ }
322
+
323
+ /**
324
+ * Background-deliver `jobId` once, deduped against in-flight jobs.
325
+ *
326
+ * `inflight` is updated SYNCHRONOUSLY here (before scheduling) so a
327
+ * concurrent notify + sweep can never double-deliver the same job.
328
+ */
329
+ private spawnJob(jobId: number, opts: { verified: boolean }): void {
330
+ if (this.inflight.has(jobId)) return;
331
+ this.inflight.add(jobId);
332
+ this.spawn(() => this.runJob(jobId, opts));
333
+ }
334
+
335
+ /**
336
+ * Background runner: deliver one job, log the outcome, free the slot.
337
+ *
338
+ * `verified` jobs (pre-verified in `notifyFunded`) skip straight to the
339
+ * work; unverified ones (the sweep) run the full verify gate first.
340
+ */
341
+ private async runJob(
342
+ jobId: number,
343
+ { verified }: { verified: boolean },
344
+ ): Promise<void> {
345
+ let terminal = false;
346
+ try {
347
+ const result = verified
348
+ ? await this.doWorkAndSubmit(jobId)
349
+ : await this.fulfillJob(jobId);
350
+ log.info(`notify_funded job ${jobId} → ${JSON.stringify(result)}`);
351
+ // A terminal outcome (delivered, or a permanent skip) must STAY in
352
+ // `inflight`: keeping it lets the dedup gate in spawnJob reject a
353
+ // slower concurrent sweep that still sees this job as FUNDED, so the
354
+ // just-submitted job is never re-delivered. Clearing on success
355
+ // reopened that race — the sweep re-ran the work and then failed the
356
+ // on-chain FUNDED gate (Job status is SUBMITTED). Only transient
357
+ // failures fall through to delete so a later sweep can retry them.
358
+ terminal = Boolean(result.ok || result.skip);
359
+ } catch (e) {
360
+ // a background job must never crash the process
361
+ log.error(`background delivery of job ${jobId} failed`, e);
362
+ } finally {
363
+ if (!terminal) {
364
+ this.inflight.delete(jobId);
365
+ }
366
+ }
367
+ }
368
+
369
+ // ── internals ─────────────────────────────────────────────────────────────
370
+
371
+ /**
372
+ * Verify the signed deal on-chain, then deliver (the sweep's per-job
373
+ * worker).
374
+ *
375
+ * VERIFY before working: confirm the funded job carries the exact quote
376
+ * THIS agent signed (ecrecover + budget ≥ price). A permanent failure
377
+ * (not our signature, tampered terms, underfunded, expired) returns
378
+ * `skip: true`; a transient one returns `ok: false` to retry.
379
+ */
380
+ private async fulfillJob(jobId: number): Promise<Record<string, unknown>> {
381
+ const v = await this.signing.verifySignedJob(jobId);
382
+ if (!v.ok) {
383
+ return { ok: false, job_id: jobId, skip: v.permanent, reason: v.reason };
384
+ }
385
+ return this.doWorkAndSubmit(jobId);
386
+ }
387
+
388
+ /**
389
+ * LLM work → sign + submit. Assumes `jobId` is already verified.
390
+ *
391
+ * DEVELOPER HOOK: the LLM block produces the deliverable text — specialise
392
+ * it for your seller. `signing.submitResult` re-runs the SDK `verifyJob`
393
+ * (defense in depth) and THROWS on a failed submit, so an `ok: true`
394
+ * result always carries a landed tx hash.
395
+ */
396
+ private async doWorkAndSubmit(
397
+ jobId: number,
398
+ ): Promise<Record<string, unknown>> {
399
+ const spec = await this.signing.jobSpec(jobId);
400
+ const task =
401
+ spec !== null
402
+ ? JSON.stringify({ task: spec.task, terms: spec.terms })
403
+ : `job ${jobId}`;
404
+ const prompt =
405
+ "You accepted and were paid for the following job. Produce the " +
406
+ "deliverable now. Be complete and self-contained.\n\n" +
407
+ `JOB CONTEXT:\n${task}`;
408
+ const work = await this.runWork(prompt, { sessionId: String(jobId) });
409
+
410
+ let res: { submitTx: string; deliverableUrl: string | null };
411
+ try {
412
+ res = await this.signing.submitResult(jobId, work, {
413
+ job_id: jobId,
414
+ generator: this.generator,
415
+ built_with: "https://github.com/bnb-chain/bnbagent-studio",
416
+ });
417
+ } catch (e) {
418
+ if (
419
+ e instanceof SubmitPermanentlyUnsupportedError ||
420
+ (e instanceof Error && e.name === "SubmitPermanentlyUnsupportedError")
421
+ ) {
422
+ // Deterministic for this wallet kind: submit can NEVER succeed →
423
+ // permanent skip (a transient error would burn one LLM call / retry).
424
+ return { ok: false, job_id: jobId, skip: true, reason: e.message };
425
+ }
426
+ throw e;
427
+ }
428
+ return {
429
+ ok: true,
430
+ job_id: jobId,
431
+ tx_hash: res.submitTx,
432
+ deliverable_url: res.deliverableUrl,
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Best-effort background fallback: deliver any FUNDED jobs for this
438
+ * provider.
439
+ *
440
+ * Catches jobs whose buyer funded on-chain but never sent `notify_funded`.
441
+ * Each job is handed to `spawnJob` (deduped against in-flight jobs, so a
442
+ * concurrent notify never double-delivers); `verifySignedJob` returns
443
+ * non-OK for an already-SUBMITTED job (idempotent, no state file). Errors
444
+ * here are logged and never surface to the caller.
445
+ */
446
+ private async sweep(): Promise<void> {
447
+ let pending: Record<string, unknown>;
448
+ try {
449
+ pending = await this.pendingJobs(this.network);
450
+ } catch (e) {
451
+ // the sweep is best-effort
452
+ log.warn(`funded-job sweep failed: ${e instanceof Error ? e.message : e}`);
453
+ return;
454
+ }
455
+ const jobs = Array.isArray(pending?.jobs) ? pending.jobs : [];
456
+ for (const job of jobs) {
457
+ const jid =
458
+ job !== null && typeof job === "object" && !Array.isArray(job)
459
+ ? (job as Record<string, unknown>).jobId
460
+ : undefined;
461
+ if (jid === undefined || jid === null) continue;
462
+ try {
463
+ this.spawnJob(parseJobId(jid), { verified: false });
464
+ } catch {
465
+ // unparseable id — skip
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ // ── wire helpers ──────────────────────────────────────────────────────────────
472
+
473
+ function inbound(context: RequestContext): Record<string, unknown> {
474
+ const parts = context.userMessage?.parts ?? [];
475
+ const dataPart = parts.find((p): p is DataPart => p.kind === "data");
476
+ return dataPart?.data ?? {};
477
+ }
478
+
479
+ function reply(
480
+ eventBus: ExecutionEventBus,
481
+ context: RequestContext,
482
+ data: Record<string, unknown>,
483
+ ): void {
484
+ const message: Message = {
485
+ kind: "message",
486
+ role: "agent",
487
+ messageId: randomUUID(),
488
+ parts: [{ kind: "data", data }],
489
+ contextId: context.contextId,
490
+ taskId: context.taskId,
491
+ };
492
+ // publish + finished() — without finished() the event stream never closes
493
+ // and the caller hangs.
494
+ eventBus.publish(message);
495
+ eventBus.finished();
496
+ }
497
+
498
+ /** Normalise an envelope `job_id` (`0x..` / decimal string / number) to int. */
499
+ export function parseJobId(raw: unknown): number {
500
+ if (typeof raw === "number" && Number.isInteger(raw)) return raw;
501
+ if (typeof raw === "bigint") return Number(raw);
502
+ // BigInt() parses both `0x..` hex and decimal strings, and throws on junk.
503
+ return Number(BigInt(String(raw).trim()));
504
+ }