@bnbagent/studio-cli 0.0.9 → 0.0.11-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.
- package/LICENSE +201 -0
- package/README.md +2 -2
- package/dist/_agentcoreName-DZDWEYD3.js +0 -0
- package/dist/_twak-4XF4H5PL.js +0 -0
- package/dist/bag.js +577 -370
- package/dist/chunk-RO726HJG.js +0 -0
- package/dist/{chunk-YFEM4564.js → chunk-TTPOH453.js} +79 -37
- package/dist/chunk-U7IDQ3K5.js +0 -0
- package/dist/{deployCli-NJFCWBSF.js → deployCli-K55GXDVO.js} +1 -1
- package/package.json +11 -12
- package/recipes/runtimes/agentcore/code/{{PKG}}/Dockerfile.tmpl +20 -21
- package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +36 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/unifiedMain.ts.tmpl +620 -0
- package/recipes/runtimes/agentcore/recipe.toml +3 -3
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/Dockerfile.tmpl +25 -23
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/agentCard.ts.tmpl +16 -12
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +72 -393
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +160 -43
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/sellerCore.ts.tmpl +504 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/unifiedMain.ts.tmpl +620 -0
- package/recipes/runtimes/azure-foundry/recipe.toml +19 -11
- package/recipes/x402-buyer/code/{{PKG}}/x402Buyer.ts.tmpl +6 -4
- package/skills/bnbagent-studio.md +2 -2
- package/skills/references/bnbagent-studio-adding-to-project.md +1 -1
- package/skills/references/bnbagent-studio-buying-from-bazaar.md +1 -1
- package/skills/references/bnbagent-studio-operating.md +4 -4
- package/skills/references/bnbagent-studio-scaffolding-agent.md +4 -4
- package/skills/references/bnbagent-studio-selling-via-8183.md +3 -3
- package/skills/references/bnbagent-studio-selling-via-b402.md +2 -2
- package/skills/references/bnbagent-studio-use-aws-agentcore.md +1 -1
- package/skills/references/bnbagent-studio-use-azure-foundry.md +3 -3
- package/skills/references/bnbagent-studio-use-bnb-trial.md +1 -1
- package/skills/references/bnbagent-studio-wiring-llm-tools.md +3 -3
- package/recipes/runtimes/agentcore/code/{{PKG}}/main.ts.tmpl +0 -347
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/foundryMain.ts.tmpl +0 -422
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/main.ts.tmpl +0 -196
|
@@ -0,0 +1,504 @@
|
|
|
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 by verified ERC-8183 delivery and, through the runtime
|
|
109
|
+
* adapter, by x402 only after its commerce gate. `abortSignal` is wired to
|
|
110
|
+
* the delivery timeout so a hung LLM call is actually cancelled.
|
|
111
|
+
*/
|
|
112
|
+
export type RunWork = (
|
|
113
|
+
prompt: string,
|
|
114
|
+
opts: { sessionId: string; abortSignal?: AbortSignal },
|
|
115
|
+
) => Promise<string>;
|
|
116
|
+
|
|
117
|
+
/** The `signing.ts` surface the core drives (injectable for tests). */
|
|
118
|
+
export interface SigningApi {
|
|
119
|
+
listPrice(): bigint;
|
|
120
|
+
clampPrice(proposedWei: bigint): bigint;
|
|
121
|
+
signQuote(
|
|
122
|
+
request: Record<string, unknown>,
|
|
123
|
+
clampedPriceWei: bigint,
|
|
124
|
+
): Promise<Record<string, unknown>>;
|
|
125
|
+
verifySignedJob(
|
|
126
|
+
jobId: number,
|
|
127
|
+
): Promise<{ ok: boolean; reason: string; permanent: boolean }>;
|
|
128
|
+
jobSpec(
|
|
129
|
+
jobId: number,
|
|
130
|
+
): Promise<{ task: string; terms: Record<string, unknown> } | null>;
|
|
131
|
+
submitResult(
|
|
132
|
+
jobId: number,
|
|
133
|
+
responseContent: string,
|
|
134
|
+
metadata?: Record<string, unknown> | null,
|
|
135
|
+
): Promise<{ submitTx: string; deliverableUrl: string | null }>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Pending-job scanner used by the sweep (injectable for tests). */
|
|
139
|
+
export type PendingJobsFetcher = (
|
|
140
|
+
network: string,
|
|
141
|
+
) => Promise<Record<string, unknown>>;
|
|
142
|
+
|
|
143
|
+
const defaultPendingJobs: PendingJobsFetcher = async (network) => {
|
|
144
|
+
const ops = await ERC8183JobOps.create({
|
|
145
|
+
walletProvider: getWallet(),
|
|
146
|
+
network,
|
|
147
|
+
});
|
|
148
|
+
return (await ops.getPendingJobs()) as Record<string, unknown>;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export interface SellerCoreOpts {
|
|
152
|
+
runWork: RunWork;
|
|
153
|
+
generator: string;
|
|
154
|
+
network?: string | null;
|
|
155
|
+
/** Whether the project configured the ERC-8183 commerce rail. */
|
|
156
|
+
commerceSkills?: boolean;
|
|
157
|
+
/** Test seam: replace the signing module (default: `./signing.js`). */
|
|
158
|
+
signing?: SigningApi;
|
|
159
|
+
/** Test seam: replace the sweep's pending-job scan. */
|
|
160
|
+
pendingJobs?: PendingJobsFetcher;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* ERC-8183 seller core: negotiate + notifyFunded, backed by signing.ts.
|
|
165
|
+
*
|
|
166
|
+
* `runWork(prompt, { sessionId })` is the LLM work hook (built in `main.ts`
|
|
167
|
+
* from the AI SDK); it is called inside the background delivery
|
|
168
|
+
* (`notifyFunded` → `doWorkAndSubmit`) to produce the deliverable text.
|
|
169
|
+
*
|
|
170
|
+
* The core exposes ONLY the two paid, structured operations — there is no
|
|
171
|
+
* free-form chat operation. The transport is responsible for routing a
|
|
172
|
+
* request to {@link negotiate} / {@link notifyFunded}; a request that names
|
|
173
|
+
* no structured operation must never trigger an LLM call or a paid action.
|
|
174
|
+
*/
|
|
175
|
+
export class SellerCore {
|
|
176
|
+
protected readonly runWork: RunWork;
|
|
177
|
+
protected readonly generator: string;
|
|
178
|
+
protected readonly network: string;
|
|
179
|
+
protected readonly signing: SigningApi;
|
|
180
|
+
private readonly commerceSkills: boolean;
|
|
181
|
+
private readonly pendingJobs: PendingJobsFetcher;
|
|
182
|
+
// Background delivery bookkeeping (see notifyFunded / isBusy):
|
|
183
|
+
// tasks — live background promises (busy-status source).
|
|
184
|
+
// inflight — job ids in flight OR already terminally handled this
|
|
185
|
+
// process (notify/sweep dedup; retained on success so a
|
|
186
|
+
// slower sweep never re-delivers a just-submitted job).
|
|
187
|
+
private readonly tasks = new Set<Promise<void>>();
|
|
188
|
+
private readonly inflight = new Set<number>();
|
|
189
|
+
|
|
190
|
+
constructor(opts: SellerCoreOpts) {
|
|
191
|
+
this.runWork = opts.runWork;
|
|
192
|
+
this.generator = opts.generator;
|
|
193
|
+
this.network = opts.network ?? "bsc-testnet";
|
|
194
|
+
this.signing = opts.signing ?? defaultSigning;
|
|
195
|
+
this.commerceSkills = opts.commerceSkills ?? true;
|
|
196
|
+
this.pendingJobs = opts.pendingJobs ?? defaultPendingJobs;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* True while any background delivery is in flight.
|
|
201
|
+
*
|
|
202
|
+
* The transport feeds this to AgentCore's `/ping` (`HEALTHY_BUSY` when
|
|
203
|
+
* busy) so the scale-to-zero runtime is not reaped on idle while work runs.
|
|
204
|
+
*/
|
|
205
|
+
isBusy(): boolean {
|
|
206
|
+
return this.tasks.size > 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Await every in-flight background task (test helper — not on the wire). */
|
|
210
|
+
async drain(): Promise<void> {
|
|
211
|
+
while (this.tasks.size > 0) {
|
|
212
|
+
await Promise.allSettled([...this.tasks]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── skills ──────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Rule-based quote → SDK `NegotiationResult` envelope (no LLM).
|
|
220
|
+
*
|
|
221
|
+
* The price is the FIXED list price from studio.toml, clamped to
|
|
222
|
+
* `[min,max]` BEFORE signing — a misconfigured or hostile request can
|
|
223
|
+
* never sign out of bounds. The buyer parses this envelope verbatim and
|
|
224
|
+
* anchors it on-chain via `createJob` + `fund`.
|
|
225
|
+
*/
|
|
226
|
+
async negotiate(
|
|
227
|
+
data: Record<string, unknown>,
|
|
228
|
+
): Promise<Record<string, unknown>> {
|
|
229
|
+
this.requireCommerceRail();
|
|
230
|
+
let request = data.request;
|
|
231
|
+
if (request === null || typeof request !== "object" || Array.isArray(request)) {
|
|
232
|
+
const picked: Record<string, unknown> = {};
|
|
233
|
+
for (const k of ["task_description", "terms"]) {
|
|
234
|
+
if (k in data) picked[k] = data[k];
|
|
235
|
+
}
|
|
236
|
+
request = picked;
|
|
237
|
+
}
|
|
238
|
+
const clamped = this.signing.clampPrice(this.signing.listPrice());
|
|
239
|
+
return this.signing.signQuote(request as Record<string, unknown>, clamped);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The seller's two advertised skills. */
|
|
243
|
+
skills(): string[] {
|
|
244
|
+
return this.commerceSkills ? ["negotiate", "notify_funded"] : [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Buyer notification: "I funded job X — please deliver."
|
|
249
|
+
*
|
|
250
|
+
* Verify the funded job synchronously (a couple of eth_calls) to ACK
|
|
251
|
+
* accepted/rejected at once, then run the slow LLM work + on-chain
|
|
252
|
+
* `submit` in a BACKGROUND task and return IMMEDIATELY. The buyer reads
|
|
253
|
+
* the deliverable back from the CHAIN (SUBMITTED / `getDeliverableUrl`) —
|
|
254
|
+
* the chain is the source of truth (see erc8183-buyer-push.md).
|
|
255
|
+
*
|
|
256
|
+
* An accepted notification also kicks a background sweep (deduped against
|
|
257
|
+
* in-flight jobs), so a buyer that funded but forgot to notify is still
|
|
258
|
+
* served while we're warm. A rejected / malformed notification spawns
|
|
259
|
+
* nothing.
|
|
260
|
+
*/
|
|
261
|
+
async notifyFunded(
|
|
262
|
+
data: Record<string, unknown>,
|
|
263
|
+
): Promise<Record<string, unknown>> {
|
|
264
|
+
this.requireCommerceRail();
|
|
265
|
+
const raw = data.job_id;
|
|
266
|
+
if (raw === undefined || raw === null || String(raw) === "") {
|
|
267
|
+
this.spawn(() => this.sweep()); // bare notify → just scan stragglers
|
|
268
|
+
return {
|
|
269
|
+
status: "accepted",
|
|
270
|
+
note: "no job_id — scanning funded jobs in the background; poll the chain for results",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
let jobId: number;
|
|
274
|
+
try {
|
|
275
|
+
jobId = parseJobId(raw);
|
|
276
|
+
} catch {
|
|
277
|
+
return { status: "rejected", error: `invalid job_id: ${JSON.stringify(raw)}` };
|
|
278
|
+
}
|
|
279
|
+
let verified = false;
|
|
280
|
+
try {
|
|
281
|
+
// Time-bounded: a hung RPC must not stall the ack path. On timeout we
|
|
282
|
+
// fall through to accept-and-re-verify below.
|
|
283
|
+
const v = await withTimeout(
|
|
284
|
+
this.signing.verifySignedJob(jobId),
|
|
285
|
+
preverifyTimeoutSeconds(),
|
|
286
|
+
);
|
|
287
|
+
if (!v.ok && v.permanent) {
|
|
288
|
+
return { status: "rejected", job_id: jobId, reason: v.reason };
|
|
289
|
+
}
|
|
290
|
+
verified = v.ok;
|
|
291
|
+
} catch (e) {
|
|
292
|
+
// pre-verify is best-effort; the background delivery re-verifies
|
|
293
|
+
log.warn(
|
|
294
|
+
`pre-verify of job ${jobId} failed (${e instanceof Error ? e.message : e}); accepting, will re-verify in background`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
this.spawnJob(jobId, { verified });
|
|
298
|
+
this.spawn(() => this.sweep()); // straggler fallback alongside the named job
|
|
299
|
+
return {
|
|
300
|
+
status: "accepted",
|
|
301
|
+
job_id: jobId,
|
|
302
|
+
note: "delivery started; poll the chain (SUBMITTED / get_deliverable_url) for the result",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── background delivery ──────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/** Run `work` as a tracked background task (keeps {@link isBusy} true). */
|
|
309
|
+
protected spawn(work: () => Promise<void>): void {
|
|
310
|
+
const task = work().catch((e) => {
|
|
311
|
+
// a background task must never crash the process
|
|
312
|
+
log.error("background task failed", e);
|
|
313
|
+
});
|
|
314
|
+
this.tasks.add(task);
|
|
315
|
+
task.finally(() => this.tasks.delete(task));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Background-deliver `jobId` once, deduped against in-flight jobs.
|
|
320
|
+
*
|
|
321
|
+
* `inflight` is updated SYNCHRONOUSLY here (before scheduling) so a
|
|
322
|
+
* concurrent notify + sweep can never double-deliver the same job.
|
|
323
|
+
*/
|
|
324
|
+
private spawnJob(jobId: number, opts: { verified: boolean }): void {
|
|
325
|
+
if (this.inflight.has(jobId)) return;
|
|
326
|
+
this.inflight.add(jobId);
|
|
327
|
+
this.spawn(() => this.runJob(jobId, opts));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Background runner: deliver one job, log the outcome, free the slot.
|
|
332
|
+
*
|
|
333
|
+
* `verified` jobs (pre-verified in `notifyFunded`) skip straight to the
|
|
334
|
+
* work; unverified ones (the sweep) run the full verify gate first.
|
|
335
|
+
*/
|
|
336
|
+
private async runJob(
|
|
337
|
+
jobId: number,
|
|
338
|
+
{ verified }: { verified: boolean },
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
let terminal = false;
|
|
341
|
+
const controller = new AbortController();
|
|
342
|
+
try {
|
|
343
|
+
// Hard ceiling so a hung delivery (e.g. unresponsive RPC) cannot keep
|
|
344
|
+
// isBusy() true — which would pin the microVM to its 8h max-lifetime.
|
|
345
|
+
// A timeout is TRANSIENT: terminal stays false, the slot is freed, and
|
|
346
|
+
// the funded job is re-delivered idempotently by a later sweep.
|
|
347
|
+
const result = await withTimeout(
|
|
348
|
+
verified
|
|
349
|
+
? this.doWorkAndSubmit(jobId, controller.signal)
|
|
350
|
+
: this.fulfillJob(jobId, controller.signal),
|
|
351
|
+
jobDeliveryTimeoutSeconds(),
|
|
352
|
+
controller,
|
|
353
|
+
);
|
|
354
|
+
log.info(`notify_funded job ${jobId} → ${JSON.stringify(result)}`);
|
|
355
|
+
// A terminal outcome (delivered, or a permanent skip) must STAY in
|
|
356
|
+
// `inflight`: keeping it lets the dedup gate in spawnJob reject a
|
|
357
|
+
// slower concurrent sweep that still sees this job as FUNDED, so the
|
|
358
|
+
// just-submitted job is never re-delivered. Clearing on success
|
|
359
|
+
// reopened that race — the sweep re-ran the work and then failed the
|
|
360
|
+
// on-chain FUNDED gate (Job status is SUBMITTED). Only transient
|
|
361
|
+
// failures fall through to delete so a later sweep can retry them.
|
|
362
|
+
terminal = Boolean(result.ok || result.skip);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
if (e instanceof DeliveryTimeoutError) {
|
|
365
|
+
// Transient by design — leave terminal false so a later sweep retries.
|
|
366
|
+
log.warn(
|
|
367
|
+
`background delivery of job ${jobId} timed out after ${jobDeliveryTimeoutSeconds()}s; will retry`,
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
log.error(`background delivery of job ${jobId} failed`, e);
|
|
371
|
+
}
|
|
372
|
+
} finally {
|
|
373
|
+
if (!terminal) {
|
|
374
|
+
this.inflight.delete(jobId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private requireCommerceRail(): void {
|
|
380
|
+
if (!this.commerceSkills) {
|
|
381
|
+
throw new Error("8183 rail disabled");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Verify the signed deal on-chain, then deliver (the sweep's per-job worker).
|
|
389
|
+
*
|
|
390
|
+
* VERIFY before working: confirm the funded job carries the exact quote
|
|
391
|
+
* THIS agent signed (ecrecover + budget ≥ price). A permanent failure
|
|
392
|
+
* (not our signature, tampered terms, underfunded, expired) returns
|
|
393
|
+
* `skip: true`; a transient one returns `ok: false` to retry.
|
|
394
|
+
*/
|
|
395
|
+
private async fulfillJob(
|
|
396
|
+
jobId: number,
|
|
397
|
+
abortSignal: AbortSignal,
|
|
398
|
+
): Promise<Record<string, unknown>> {
|
|
399
|
+
const v = await this.signing.verifySignedJob(jobId);
|
|
400
|
+
if (!v.ok) {
|
|
401
|
+
return { ok: false, job_id: jobId, skip: v.permanent, reason: v.reason };
|
|
402
|
+
}
|
|
403
|
+
return this.doWorkAndSubmit(jobId, abortSignal);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* LLM work → sign + submit. Assumes `jobId` is already verified.
|
|
408
|
+
*
|
|
409
|
+
* DEVELOPER HOOK: the LLM block produces the deliverable text — specialise
|
|
410
|
+
* it for your seller. `signing.submitResult` re-runs the SDK `verifyJob`
|
|
411
|
+
* (defense in depth) and THROWS on a failed submit, so an `ok: true`
|
|
412
|
+
* result always carries a landed tx hash.
|
|
413
|
+
*/
|
|
414
|
+
protected async doWorkAndSubmit(
|
|
415
|
+
jobId: number,
|
|
416
|
+
abortSignal?: AbortSignal,
|
|
417
|
+
): Promise<Record<string, unknown>> {
|
|
418
|
+
const spec = await this.signing.jobSpec(jobId);
|
|
419
|
+
const task =
|
|
420
|
+
spec !== null
|
|
421
|
+
? JSON.stringify({ task: spec.task, terms: spec.terms })
|
|
422
|
+
: `job ${jobId}`;
|
|
423
|
+
const prompt =
|
|
424
|
+
"You accepted and were paid for the following job. Produce the " +
|
|
425
|
+
"deliverable now. Be complete and self-contained.\n\n" +
|
|
426
|
+
`JOB CONTEXT:\n${task}`;
|
|
427
|
+
const work = await this.runWork(prompt, {
|
|
428
|
+
sessionId: String(jobId),
|
|
429
|
+
abortSignal,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
let res: { submitTx: string; deliverableUrl: string | null };
|
|
433
|
+
try {
|
|
434
|
+
res = await this.signing.submitResult(jobId, work, {
|
|
435
|
+
job_id: jobId,
|
|
436
|
+
generator: this.generator,
|
|
437
|
+
built_with: "https://github.com/bnb-chain/bnbagent-studio",
|
|
438
|
+
});
|
|
439
|
+
} catch (e) {
|
|
440
|
+
if (
|
|
441
|
+
e instanceof SubmitPermanentlyUnsupportedError ||
|
|
442
|
+
(e instanceof Error && e.name === "SubmitPermanentlyUnsupportedError")
|
|
443
|
+
) {
|
|
444
|
+
// Deterministic for this wallet kind: submit can NEVER succeed →
|
|
445
|
+
// permanent skip (a transient error would burn one LLM call / retry).
|
|
446
|
+
return { ok: false, job_id: jobId, skip: true, reason: e.message };
|
|
447
|
+
}
|
|
448
|
+
throw e;
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
ok: true,
|
|
452
|
+
job_id: jobId,
|
|
453
|
+
tx_hash: res.submitTx,
|
|
454
|
+
deliverable_url: res.deliverableUrl,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Best-effort background fallback: deliver any FUNDED jobs for this
|
|
460
|
+
* provider.
|
|
461
|
+
*
|
|
462
|
+
* Catches jobs whose buyer funded on-chain but never sent `notify_funded`.
|
|
463
|
+
* Each job is handed to `spawnJob` (deduped against in-flight jobs, so a
|
|
464
|
+
* concurrent notify never double-delivers); `verifySignedJob` returns
|
|
465
|
+
* non-OK for an already-SUBMITTED job (idempotent, no state file). Errors
|
|
466
|
+
* here are logged and never surface to the caller.
|
|
467
|
+
*/
|
|
468
|
+
private async sweep(): Promise<void> {
|
|
469
|
+
let pending: Record<string, unknown>;
|
|
470
|
+
try {
|
|
471
|
+
// Time-bounded: a hung scan would otherwise keep isBusy() true (it
|
|
472
|
+
// runs on every notify) and pin the microVM to its 8h max-lifetime.
|
|
473
|
+
pending = await withTimeout(
|
|
474
|
+
this.pendingJobs(this.network),
|
|
475
|
+
sweepTimeoutSeconds(),
|
|
476
|
+
);
|
|
477
|
+
} catch (e) {
|
|
478
|
+
// the sweep is best-effort (incl. timeouts)
|
|
479
|
+
log.warn(`funded-job sweep failed: ${e instanceof Error ? e.message : e}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const jobs = Array.isArray(pending?.jobs) ? pending.jobs : [];
|
|
483
|
+
for (const job of jobs) {
|
|
484
|
+
const jid =
|
|
485
|
+
job !== null && typeof job === "object" && !Array.isArray(job)
|
|
486
|
+
? (job as Record<string, unknown>).jobId
|
|
487
|
+
: undefined;
|
|
488
|
+
if (jid === undefined || jid === null) continue;
|
|
489
|
+
try {
|
|
490
|
+
this.spawnJob(parseJobId(jid), { verified: false });
|
|
491
|
+
} catch {
|
|
492
|
+
// unparseable id — skip
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
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
|
+
}
|