@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,117 @@
1
+ /**
2
+ * Model factory — emitted user code shared by every runtime entrypoint.
3
+ *
4
+ * This file is **your project's code**, scaffolded by `bag` and emitted at
5
+ * `{{PKG}}/model.ts` for every project, whatever `[llm].provider` you chose.
6
+ * It is yours to edit, fork, or replace — studio will not silently rewrite
7
+ * it.
8
+ *
9
+ * What it does:
10
+ *
11
+ * - Exposes {@link buildModel}, the factory called by the sibling entrypoint
12
+ * to construct the right AI SDK `LanguageModel` for the project's `[llm]`
13
+ * config. For every provider it resolves a plain model via
14
+ * `@bnbagent/studio-runtime/llm` `resolveModel`; for `pieverse-llm` (with
15
+ * auto-renew on) it additionally wraps it with a credit-ensure middleware.
16
+ * - The middleware awaits a Pieverse credit-ensure hook before every
17
+ * generate/stream call — inert unless the provider is `pieverse-llm`.
18
+ *
19
+ * The credit-refresh / auto-allocate / auto-topup logic itself lives in the
20
+ * library at `@bnbagent/studio-runtime/pieverse` `PieverseCreditEnsurer` —
21
+ * this shell just wires it into the AI SDK's generate-call path (via
22
+ * `wrapLanguageModel`, the AI SDK's sanctioned middleware seam). That keeps
23
+ * the adapter tiny: you can fork *this file* (e.g. to swap the AI SDK for
24
+ * another stack) without forking studio itself.
25
+ *
26
+ * That top-up is the ONLY automatic signing path outside signing.ts — it is
27
+ * budget-gated and is NOT an LLM tool. It rides transparently into the
28
+ * `notify_funded` work step.
29
+ */
30
+
31
+ import { loadStudioToml, type TomlTable } from "@bnbagent/studio-runtime/config";
32
+ import { resolveModel } from "@bnbagent/studio-runtime/llm";
33
+ import {
34
+ BudgetPolicy,
35
+ PieverseCreditEnsurer,
36
+ PieversePolicy,
37
+ } from "@bnbagent/studio-runtime/pieverse";
38
+ import { getWallet } from "@bnbagent/studio-runtime/wallet";
39
+ import {
40
+ type LanguageModel,
41
+ type LanguageModelMiddleware,
42
+ wrapLanguageModel,
43
+ } from "ai";
44
+
45
+ /**
46
+ * Build the AI SDK model object for this project's `[llm]` config.
47
+ *
48
+ * Called by the sibling entrypoint. Reads `studio.toml` via
49
+ * `loadStudioToml`, resolves the provider via
50
+ * `@bnbagent/studio-runtime/llm` `resolveModel`, and (when the provider is
51
+ * `pieverse-llm` and auto-renew is enabled) wraps it with the credit-ensure
52
+ * middleware.
53
+ *
54
+ * For non-Pieverse providers — or when `[llm.auto_renew].enabled = false` —
55
+ * returns the raw inner model unwrapped.
56
+ */
57
+ export function buildModel(): LanguageModel {
58
+ const cfg = loadStudioToml();
59
+ const llmCfg = (cfg.llm ?? {}) as TomlTable;
60
+ const inner = resolveModel(llmCfg);
61
+
62
+ if (String(llmCfg.provider ?? "openrouter") !== "pieverse-llm") {
63
+ return inner;
64
+ }
65
+
66
+ // Pieverse path — wrap with the credit-ensure middleware unless auto-renew
67
+ // opted out.
68
+ const autoRenewCfg = (llmCfg.auto_renew ?? {}) as TomlTable;
69
+ const pieverseCfg = (llmCfg.pieverse ?? {}) as TomlTable;
70
+ const budgetCfg = (cfg.budget ?? {}) as TomlTable;
71
+
72
+ const policy = PieversePolicy.fromToml(autoRenewCfg);
73
+ if (!policy.enabled) {
74
+ return inner;
75
+ }
76
+
77
+ const keyHash = pieverseCfg.key_hash;
78
+ if (!keyHash) {
79
+ throw new Error(
80
+ "[llm.pieverse].key_hash is missing in studio.toml. " +
81
+ "Run `bag llm activate` to create a Pieverse key first. " +
82
+ "(After activate, you may need to restart the agent process " +
83
+ "for changes to take effect.)",
84
+ );
85
+ }
86
+ const networkName = String(pieverseCfg.network ?? "bsc-mainnet");
87
+ const budgetPolicy = BudgetPolicy.fromToml(budgetCfg);
88
+
89
+ const ensurer = new PieverseCreditEnsurer({
90
+ modelId: String(llmCfg.model ?? ""),
91
+ wallet: getWallet(),
92
+ keyHash: String(keyHash),
93
+ networkName,
94
+ policy,
95
+ budgetPolicy,
96
+ });
97
+
98
+ // The AI SDK middleware seam: ensure credits BEFORE each generate/stream
99
+ // call, then delegate untouched through the AI SDK middleware seam.
100
+ const creditEnsure: LanguageModelMiddleware = {
101
+ wrapGenerate: async ({ doGenerate }) => {
102
+ await ensurer.ensureCredits();
103
+ return doGenerate();
104
+ },
105
+ wrapStream: async ({ doStream }) => {
106
+ await ensurer.ensureCredits();
107
+ return doStream();
108
+ },
109
+ };
110
+
111
+ // resolveModel always returns a provider model object (never a bare model
112
+ // id string), so it satisfies wrapLanguageModel's model parameter.
113
+ return wrapLanguageModel({
114
+ model: inner as Parameters<typeof wrapLanguageModel>[0]["model"],
115
+ middleware: creditEnsure,
116
+ });
117
+ }
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Seller core — the a2a-free seller logic + background delivery machinery.
3
+ *
4
+ * This is the protocol-neutral heart of the ERC-8183 seller: the two fixed-code
5
+ * operations (`negotiate` → signed quote; `notifyFunded` → verify → ACK →
6
+ * deliver in the background) plus the background-delivery bookkeeping
7
+ * (`isBusy`, the spawn/run/sweep helpers). It imports NOTHING from
8
+ * `@a2a-js/sdk` so it can back any transport — the A2A executor
9
+ * (`executor.ts`) inherits it and wraps it with the a2a wire, and a non-A2A
10
+ * HTTP entrypoint can call it directly without dragging in the a2a sdk.
11
+ *
12
+ * negotiate → `signing.signQuote` (rule-based price clamp + EIP-191 sign)
13
+ * notifyFunded → `signing.verifySignedJob` (fast on-chain gate) → ACK at
14
+ * once, then in the BACKGROUND: LLM work → `signing.submitResult`
15
+ *
16
+ * `notifyFunded` is the buyer's "I funded job X — please deliver" notification.
17
+ * Because the work takes time, it does NOT block the caller: it verifies the
18
+ * funded job synchronously (a couple of eth_calls) to ACK accepted/rejected,
19
+ * then runs the slow LLM work + on-chain `submit` in a background task and
20
+ * returns immediately. The buyer reads the deliverable back from the CHAIN
21
+ * (SUBMITTED / `getDeliverableUrl`) — the chain is the source of truth. While
22
+ * any background delivery is in flight {@link SellerCore.isBusy} reports busy,
23
+ * which the transport feeds to AgentCore's `/ping` as `HEALTHY_BUSY` so the
24
+ * scale-to-zero runtime stays warm until the work lands (within the session
25
+ * max-lifetime).
26
+ *
27
+ * ALL signing is FIXED code in `signing.ts` — NEVER an LLM-callable tool
28
+ * (money is never in the LLM; the LLM only produces the work text, via the
29
+ * `runWork` hook). On each notification the core also opportunistically sweeps
30
+ * OTHER funded jobs assigned to this provider — the buyer-push fallback for
31
+ * jobs whose buyer funded on-chain but never sent `notify_funded` (deduped
32
+ * against in-flight jobs). Negotiate stays sweep-free so quotes are fast. A
33
+ * periodic Lambda poller — which also covers the scale-to-zero cold window
34
+ * when no one is invoking — is the v2 robust path.
35
+ *
36
+ * You own this file — specialise the work hook / dispatch, but keep signing
37
+ * OUT of the LLM tool list.
38
+ */
39
+
40
+ import { ERC8183JobOps } from "@bnbagent/sdk/erc8183";
41
+ import { SubmitPermanentlyUnsupportedError } from "@bnbagent/studio-runtime/erc8183";
42
+ import { getWallet } from "@bnbagent/studio-runtime/wallet";
43
+ import * as defaultSigning from "./signing.js";
44
+
45
+ const log = {
46
+ info: (msg: string) => console.log(`[seller-agent.core] ${msg}`),
47
+ warn: (msg: string) => console.warn(`[seller-agent.core] WARNING ${msg}`),
48
+ error: (msg: string, e?: unknown) =>
49
+ console.error(`[seller-agent.core] ERROR ${msg}`, e ?? ""),
50
+ };
51
+
52
+ /** Read a positive timeout (seconds) from the env, falling back to `dflt`. */
53
+ function envSeconds(name: string, dflt: number): number {
54
+ const v = Number(process.env[name] || dflt);
55
+ return Number.isFinite(v) && v > 0 ? v : dflt;
56
+ }
57
+
58
+ // Background-task ceilings. notifyFunded ACKs immediately and delivers in a
59
+ // BACKGROUND task; AgentCore keeps the scale-to-zero microVM warm
60
+ // (HEALTHY_BUSY) while isBusy() is true. A delivery (LLM text + on-chain
61
+ // submit + IPFS pin) normally finishes in ~1-2 min, so these caps sit far
62
+ // above real work and only fire on a HANG (e.g. an unresponsive RPC) —
63
+ // without them a hung task keeps the VM pinned to its 8h max-lifetime,
64
+ // billing memory the whole time. A timed-out job is treated as TRANSIENT
65
+ // (not dropped): the funded job stays on-chain and a later sweep re-delivers
66
+ // it idempotently. (Read lazily so tests can tune them via the env.)
67
+ const jobDeliveryTimeoutSeconds = () =>
68
+ envSeconds("NOTIFY_DELIVERY_TIMEOUT_SECONDS", 600);
69
+ const sweepTimeoutSeconds = () => envSeconds("NOTIFY_SWEEP_TIMEOUT_SECONDS", 60);
70
+ const preverifyTimeoutSeconds = () =>
71
+ envSeconds("NOTIFY_PREVERIFY_TIMEOUT_SECONDS", 30);
72
+
73
+ /** Rejection raised by {@link withTimeout} when the deadline fires. */
74
+ export class DeliveryTimeoutError extends Error {}
75
+
76
+ /**
77
+ * Race `work` against a deadline, aborting `controller` when it fires.
78
+ *
79
+ * JS cannot hard-cancel an arbitrary promise the way asyncio.wait_for
80
+ * cancels a coroutine: the abort signal stops the LLM call (the AI SDK
81
+ * honours it), and the on-chain layers are idempotent — `verifySignedJob`
82
+ * returns non-OK for an already-SUBMITTED job and `submitResult` re-verifies
83
+ * FUNDED — so an orphaned straggler can never double-deliver.
84
+ */
85
+ async function withTimeout<T>(
86
+ work: Promise<T>,
87
+ seconds: number,
88
+ controller?: AbortController,
89
+ ): Promise<T> {
90
+ let timer: ReturnType<typeof setTimeout> | undefined;
91
+ const deadline = new Promise<never>((_, reject) => {
92
+ timer = setTimeout(() => {
93
+ controller?.abort();
94
+ reject(new DeliveryTimeoutError(`timed out after ${seconds}s`));
95
+ }, seconds * 1000);
96
+ });
97
+ try {
98
+ return await Promise.race([work, deadline]);
99
+ } finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The LLM work hook: produce the deliverable text for a prompt.
106
+ *
107
+ * Built in `main.ts` from the AI SDK (`generateText` + the read-only chain
108
+ * tools); called ONLY inside the background delivery. `abortSignal` is wired
109
+ * to the delivery timeout so a hung LLM call is actually cancelled.
110
+ */
111
+ export type RunWork = (
112
+ prompt: string,
113
+ opts: { sessionId: string; abortSignal?: AbortSignal },
114
+ ) => Promise<string>;
115
+
116
+ /** The `signing.ts` surface the core drives (injectable for tests). */
117
+ export interface SigningApi {
118
+ listPrice(): bigint;
119
+ clampPrice(proposedWei: bigint): bigint;
120
+ signQuote(
121
+ request: Record<string, unknown>,
122
+ clampedPriceWei: bigint,
123
+ ): Promise<Record<string, unknown>>;
124
+ verifySignedJob(
125
+ jobId: number,
126
+ ): Promise<{ ok: boolean; reason: string; permanent: boolean }>;
127
+ jobSpec(
128
+ jobId: number,
129
+ ): Promise<{ task: string; terms: Record<string, unknown> } | null>;
130
+ submitResult(
131
+ jobId: number,
132
+ responseContent: string,
133
+ metadata?: Record<string, unknown> | null,
134
+ ): Promise<{ submitTx: string; deliverableUrl: string | null }>;
135
+ }
136
+
137
+ /** Pending-job scanner used by the sweep (injectable for tests). */
138
+ export type PendingJobsFetcher = (
139
+ network: string,
140
+ ) => Promise<Record<string, unknown>>;
141
+
142
+ const defaultPendingJobs: PendingJobsFetcher = async (network) => {
143
+ const ops = await ERC8183JobOps.create({
144
+ walletProvider: getWallet(),
145
+ network,
146
+ });
147
+ return (await ops.getPendingJobs()) as Record<string, unknown>;
148
+ };
149
+
150
+ export interface SellerCoreOpts {
151
+ runWork: RunWork;
152
+ generator: string;
153
+ network?: string | null;
154
+ /** Whether the project configured the ERC-8183 commerce rail. */
155
+ commerceSkills?: boolean;
156
+ /** Test seam: replace the signing module (default: `./signing.js`). */
157
+ signing?: SigningApi;
158
+ /** Test seam: replace the sweep's pending-job scan. */
159
+ pendingJobs?: PendingJobsFetcher;
160
+ }
161
+
162
+ /**
163
+ * ERC-8183 seller core: negotiate + notifyFunded, backed by signing.ts.
164
+ *
165
+ * `runWork(prompt, { sessionId })` is the LLM work hook (built in `main.ts`
166
+ * from the AI SDK); it is called inside the background delivery
167
+ * (`notifyFunded` → `doWorkAndSubmit`) to produce the deliverable text.
168
+ *
169
+ * The core exposes ONLY the two paid, structured operations — there is no
170
+ * free-form chat operation. The transport is responsible for routing a
171
+ * request to {@link negotiate} / {@link notifyFunded}; a request that names
172
+ * no structured operation must never trigger an LLM call or a paid action.
173
+ */
174
+ export class SellerCore {
175
+ protected readonly runWork: RunWork;
176
+ protected readonly generator: string;
177
+ protected readonly network: string;
178
+ protected readonly signing: SigningApi;
179
+ private readonly commerceSkills: boolean;
180
+ private readonly pendingJobs: PendingJobsFetcher;
181
+ // Background delivery bookkeeping (see notifyFunded / isBusy):
182
+ // tasks — live background promises (busy-status source).
183
+ // inflight — job ids in flight OR already terminally handled this
184
+ // process (notify/sweep dedup; retained on success so a
185
+ // slower sweep never re-delivers a just-submitted job).
186
+ private readonly tasks = new Set<Promise<void>>();
187
+ private readonly inflight = new Set<number>();
188
+
189
+ constructor(opts: SellerCoreOpts) {
190
+ this.runWork = opts.runWork;
191
+ this.generator = opts.generator;
192
+ this.network = opts.network ?? "bsc-testnet";
193
+ this.signing = opts.signing ?? defaultSigning;
194
+ this.commerceSkills = opts.commerceSkills ?? true;
195
+ this.pendingJobs = opts.pendingJobs ?? defaultPendingJobs;
196
+ }
197
+
198
+ /**
199
+ * True while any background delivery is in flight.
200
+ *
201
+ * The transport feeds this to AgentCore's `/ping` (`HEALTHY_BUSY` when
202
+ * busy) so the scale-to-zero runtime is not reaped on idle while work runs.
203
+ */
204
+ isBusy(): boolean {
205
+ return this.tasks.size > 0;
206
+ }
207
+
208
+ /** Await every in-flight background task (test helper — not on the wire). */
209
+ async drain(): Promise<void> {
210
+ while (this.tasks.size > 0) {
211
+ await Promise.allSettled([...this.tasks]);
212
+ }
213
+ }
214
+
215
+ // ── skills ──────────────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Rule-based quote → SDK `NegotiationResult` envelope (no LLM).
219
+ *
220
+ * The price is the FIXED list price from studio.toml, clamped to
221
+ * `[min,max]` BEFORE signing — a misconfigured or hostile request can
222
+ * never sign out of bounds. The buyer parses this envelope verbatim and
223
+ * anchors it on-chain via `createJob` + `fund`.
224
+ */
225
+ async negotiate(
226
+ data: Record<string, unknown>,
227
+ ): Promise<Record<string, unknown>> {
228
+ this.requireCommerceRail();
229
+ let request = data.request;
230
+ if (request === null || typeof request !== "object" || Array.isArray(request)) {
231
+ const picked: Record<string, unknown> = {};
232
+ for (const k of ["task_description", "terms"]) {
233
+ if (k in data) picked[k] = data[k];
234
+ }
235
+ request = picked;
236
+ }
237
+ const clamped = this.signing.clampPrice(this.signing.listPrice());
238
+ return this.signing.signQuote(request as Record<string, unknown>, clamped);
239
+ }
240
+
241
+ /** The seller's two advertised skills. */
242
+ skills(): string[] {
243
+ return this.commerceSkills ? ["negotiate", "notify_funded"] : [];
244
+ }
245
+
246
+ /**
247
+ * Buyer notification: "I funded job X — please deliver."
248
+ *
249
+ * Verify the funded job synchronously (a couple of eth_calls) to ACK
250
+ * accepted/rejected at once, then run the slow LLM work + on-chain
251
+ * `submit` in a BACKGROUND task and return IMMEDIATELY. The buyer reads
252
+ * the deliverable back from the CHAIN (SUBMITTED / `getDeliverableUrl`) —
253
+ * the chain is the source of truth (see erc8183-buyer-push.md).
254
+ *
255
+ * An accepted notification also kicks a background sweep (deduped against
256
+ * in-flight jobs), so a buyer that funded but forgot to notify is still
257
+ * served while we're warm. A rejected / malformed notification spawns
258
+ * nothing.
259
+ */
260
+ async notifyFunded(
261
+ data: Record<string, unknown>,
262
+ ): Promise<Record<string, unknown>> {
263
+ this.requireCommerceRail();
264
+ const raw = data.job_id;
265
+ if (raw === undefined || raw === null || String(raw) === "") {
266
+ this.spawn(() => this.sweep()); // bare notify → just scan stragglers
267
+ return {
268
+ status: "accepted",
269
+ note: "no job_id — scanning funded jobs in the background; poll the chain for results",
270
+ };
271
+ }
272
+ let jobId: number;
273
+ try {
274
+ jobId = parseJobId(raw);
275
+ } catch {
276
+ return { status: "rejected", error: `invalid job_id: ${JSON.stringify(raw)}` };
277
+ }
278
+ let verified = false;
279
+ try {
280
+ // Time-bounded: a hung RPC must not stall the ack path. On timeout we
281
+ // fall through to accept-and-re-verify below.
282
+ const v = await withTimeout(
283
+ this.signing.verifySignedJob(jobId),
284
+ preverifyTimeoutSeconds(),
285
+ );
286
+ if (!v.ok && v.permanent) {
287
+ return { status: "rejected", job_id: jobId, reason: v.reason };
288
+ }
289
+ verified = v.ok;
290
+ } catch (e) {
291
+ // pre-verify is best-effort; the background delivery re-verifies
292
+ log.warn(
293
+ `pre-verify of job ${jobId} failed (${e instanceof Error ? e.message : e}); accepting, will re-verify in background`,
294
+ );
295
+ }
296
+ this.spawnJob(jobId, { verified });
297
+ this.spawn(() => this.sweep()); // straggler fallback alongside the named job
298
+ return {
299
+ status: "accepted",
300
+ job_id: jobId,
301
+ note: "delivery started; poll the chain (SUBMITTED / get_deliverable_url) for the result",
302
+ };
303
+ }
304
+
305
+ // ── background delivery ──────────────────────────────────────────────────
306
+
307
+ /** Run `work` as a tracked background task (keeps {@link isBusy} true). */
308
+ protected spawn(work: () => Promise<void>): void {
309
+ const task = work().catch((e) => {
310
+ // a background task must never crash the process
311
+ log.error("background task failed", e);
312
+ });
313
+ this.tasks.add(task);
314
+ task.finally(() => this.tasks.delete(task));
315
+ }
316
+
317
+ /**
318
+ * Background-deliver `jobId` once, deduped against in-flight jobs.
319
+ *
320
+ * `inflight` is updated SYNCHRONOUSLY here (before scheduling) so a
321
+ * concurrent notify + sweep can never double-deliver the same job.
322
+ */
323
+ private spawnJob(jobId: number, opts: { verified: boolean }): void {
324
+ if (this.inflight.has(jobId)) return;
325
+ this.inflight.add(jobId);
326
+ this.spawn(() => this.runJob(jobId, opts));
327
+ }
328
+
329
+ /**
330
+ * Background runner: deliver one job, log the outcome, free the slot.
331
+ *
332
+ * `verified` jobs (pre-verified in `notifyFunded`) skip straight to the
333
+ * work; unverified ones (the sweep) run the full verify gate first.
334
+ */
335
+ private async runJob(
336
+ jobId: number,
337
+ { verified }: { verified: boolean },
338
+ ): Promise<void> {
339
+ let terminal = false;
340
+ const controller = new AbortController();
341
+ try {
342
+ // Hard ceiling so a hung delivery (e.g. unresponsive RPC) cannot keep
343
+ // isBusy() true — which would pin the microVM to its 8h max-lifetime.
344
+ // A timeout is TRANSIENT: terminal stays false, the slot is freed, and
345
+ // the funded job is re-delivered idempotently by a later sweep.
346
+ const result = await withTimeout(
347
+ verified
348
+ ? this.doWorkAndSubmit(jobId, controller.signal)
349
+ : this.fulfillJob(jobId, controller.signal),
350
+ jobDeliveryTimeoutSeconds(),
351
+ controller,
352
+ );
353
+ log.info(`notify_funded job ${jobId} → ${JSON.stringify(result)}`);
354
+ // A terminal outcome (delivered, or a permanent skip) must STAY in
355
+ // `inflight`: keeping it lets the dedup gate in spawnJob reject a
356
+ // slower concurrent sweep that still sees this job as FUNDED, so the
357
+ // just-submitted job is never re-delivered. Clearing on success
358
+ // reopened that race — the sweep re-ran the work and then failed the
359
+ // on-chain FUNDED gate (Job status is SUBMITTED). Only transient
360
+ // failures fall through to delete so a later sweep can retry them.
361
+ terminal = Boolean(result.ok || result.skip);
362
+ } catch (e) {
363
+ if (e instanceof DeliveryTimeoutError) {
364
+ // Transient by design — leave terminal false so a later sweep retries.
365
+ log.warn(
366
+ `background delivery of job ${jobId} timed out after ${jobDeliveryTimeoutSeconds()}s; will retry`,
367
+ );
368
+ } else {
369
+ log.error(`background delivery of job ${jobId} failed`, e);
370
+ }
371
+ } finally {
372
+ if (!terminal) {
373
+ this.inflight.delete(jobId);
374
+ }
375
+ }
376
+ }
377
+
378
+ private requireCommerceRail(): void {
379
+ if (!this.commerceSkills) {
380
+ throw new Error("8183 rail disabled");
381
+ }
382
+ }
383
+
384
+ // ── internals ────────────────────────────────────────────────────────────
385
+
386
+ /**
387
+ * Verify the signed deal on-chain, then deliver (the sweep's per-job worker).
388
+ *
389
+ * VERIFY before working: confirm the funded job carries the exact quote
390
+ * THIS agent signed (ecrecover + budget ≥ price). A permanent failure
391
+ * (not our signature, tampered terms, underfunded, expired) returns
392
+ * `skip: true`; a transient one returns `ok: false` to retry.
393
+ */
394
+ private async fulfillJob(
395
+ jobId: number,
396
+ abortSignal: AbortSignal,
397
+ ): Promise<Record<string, unknown>> {
398
+ const v = await this.signing.verifySignedJob(jobId);
399
+ if (!v.ok) {
400
+ return { ok: false, job_id: jobId, skip: v.permanent, reason: v.reason };
401
+ }
402
+ return this.doWorkAndSubmit(jobId, abortSignal);
403
+ }
404
+
405
+ /**
406
+ * LLM work → sign + submit. Assumes `jobId` is already verified.
407
+ *
408
+ * DEVELOPER HOOK: the LLM block produces the deliverable text — specialise
409
+ * it for your seller. `signing.submitResult` re-runs the SDK `verifyJob`
410
+ * (defense in depth) and THROWS on a failed submit, so an `ok: true`
411
+ * result always carries a landed tx hash.
412
+ */
413
+ protected async doWorkAndSubmit(
414
+ jobId: number,
415
+ abortSignal?: AbortSignal,
416
+ ): Promise<Record<string, unknown>> {
417
+ const spec = await this.signing.jobSpec(jobId);
418
+ const task =
419
+ spec !== null
420
+ ? JSON.stringify({ task: spec.task, terms: spec.terms })
421
+ : `job ${jobId}`;
422
+ const prompt =
423
+ "You accepted and were paid for the following job. Produce the " +
424
+ "deliverable now. Be complete and self-contained.\n\n" +
425
+ `JOB CONTEXT:\n${task}`;
426
+ const work = await this.runWork(prompt, {
427
+ sessionId: String(jobId),
428
+ abortSignal,
429
+ });
430
+
431
+ let res: { submitTx: string; deliverableUrl: string | null };
432
+ try {
433
+ res = await this.signing.submitResult(jobId, work, {
434
+ job_id: jobId,
435
+ generator: this.generator,
436
+ built_with: "https://github.com/bnb-chain/bnbagent-studio",
437
+ });
438
+ } catch (e) {
439
+ if (
440
+ e instanceof SubmitPermanentlyUnsupportedError ||
441
+ (e instanceof Error && e.name === "SubmitPermanentlyUnsupportedError")
442
+ ) {
443
+ // Deterministic for this wallet kind: submit can NEVER succeed →
444
+ // permanent skip (a transient error would burn one LLM call / retry).
445
+ return { ok: false, job_id: jobId, skip: true, reason: e.message };
446
+ }
447
+ throw e;
448
+ }
449
+ return {
450
+ ok: true,
451
+ job_id: jobId,
452
+ tx_hash: res.submitTx,
453
+ deliverable_url: res.deliverableUrl,
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Best-effort background fallback: deliver any FUNDED jobs for this
459
+ * provider.
460
+ *
461
+ * Catches jobs whose buyer funded on-chain but never sent `notify_funded`.
462
+ * Each job is handed to `spawnJob` (deduped against in-flight jobs, so a
463
+ * concurrent notify never double-delivers); `verifySignedJob` returns
464
+ * non-OK for an already-SUBMITTED job (idempotent, no state file). Errors
465
+ * here are logged and never surface to the caller.
466
+ */
467
+ private async sweep(): Promise<void> {
468
+ let pending: Record<string, unknown>;
469
+ try {
470
+ // Time-bounded: a hung scan would otherwise keep isBusy() true (it
471
+ // runs on every notify) and pin the microVM to its 8h max-lifetime.
472
+ pending = await withTimeout(
473
+ this.pendingJobs(this.network),
474
+ sweepTimeoutSeconds(),
475
+ );
476
+ } catch (e) {
477
+ // the sweep is best-effort (incl. timeouts)
478
+ log.warn(`funded-job sweep failed: ${e instanceof Error ? e.message : e}`);
479
+ return;
480
+ }
481
+ const jobs = Array.isArray(pending?.jobs) ? pending.jobs : [];
482
+ for (const job of jobs) {
483
+ const jid =
484
+ job !== null && typeof job === "object" && !Array.isArray(job)
485
+ ? (job as Record<string, unknown>).jobId
486
+ : undefined;
487
+ if (jid === undefined || jid === null) continue;
488
+ try {
489
+ this.spawnJob(parseJobId(jid), { verified: false });
490
+ } catch {
491
+ // unparseable id — skip
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ /** Normalise an envelope `job_id` (`0x..` / decimal string / number) to int. */
498
+ export function parseJobId(raw: unknown): number {
499
+ if (typeof raw === "number" && Number.isInteger(raw)) return raw;
500
+ if (typeof raw === "bigint") return Number(raw);
501
+ // BigInt() parses both `0x..` hex and decimal strings, and throws on junk.
502
+ return Number(BigInt(String(raw).trim()));
503
+ }