@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.
- package/DISCLAIMER.md +48 -0
- package/LICENSE +201 -0
- package/dist/_agentcoreName-DZDWEYD3.js +7 -0
- package/dist/_twak-5XQMOFUC.js +25 -0
- package/dist/bag.js +19358 -0
- package/dist/chunk-7RAKL4AS.js +172 -0
- package/dist/chunk-M3ODFCA7.js +1053 -0
- package/dist/chunk-U7IDQ3K5.js +14 -0
- package/dist/deployCli-N6TPN6XA.js +40 -0
- package/package.json +64 -0
- package/recipes/agent/code/{{PKG}}/signing.ts.tmpl +287 -0
- package/recipes/agent/recipe.toml +35 -0
- package/recipes/providers/pieverse-llm/recipe.toml +16 -0
- package/recipes/providers/pieverse-llm/skills/funding-pieverse-llm.md +203 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/.dockerignore.tmpl +8 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/Dockerfile.tmpl +50 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/agentCard.ts.tmpl +135 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/dualMain.ts.tmpl +402 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +147 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/main.ts.tmpl +344 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/mcpMain.ts.tmpl +677 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/model.ts.tmpl +117 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/sellerCore.ts.tmpl +503 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/tools.ts.tmpl +157 -0
- package/recipes/runtimes/agentcore/recipe.toml +97 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/.dockerignore.tmpl +8 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/Dockerfile.tmpl +47 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/agentCard.ts.tmpl +131 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +504 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/foundryMain.ts.tmpl +300 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/main.ts.tmpl +196 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +562 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/model.ts.tmpl +117 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/tools.ts.tmpl +157 -0
- package/recipes/runtimes/azure-foundry/recipe.toml +88 -0
- package/recipes/tools-chain/code/{{PKG}}/chainTools.ts.tmpl +166 -0
- package/recipes/tools-chain/recipe.toml +11 -0
- package/recipes/wallet/recipe.toml +20 -0
- package/recipes/x402-buyer/code/{{PKG}}/x402Buyer.ts.tmpl +175 -0
- package/recipes/x402-buyer/recipe.toml +15 -0
- package/skills/bnbagent-studio.md +107 -0
- package/skills/references/bnbagent-studio-adding-to-project.md +241 -0
- package/skills/references/bnbagent-studio-buying-from-bazaar.md +169 -0
- package/skills/references/bnbagent-studio-buying-via-8183.md +222 -0
- package/skills/references/bnbagent-studio-extending-signing.md +227 -0
- package/skills/references/bnbagent-studio-operating.md +211 -0
- package/skills/references/bnbagent-studio-scaffolding-agent.md +536 -0
- package/skills/references/bnbagent-studio-selling-via-8183.md +271 -0
- package/skills/references/bnbagent-studio-selling-via-b402.md +194 -0
- package/skills/references/bnbagent-studio-use-aws-agentcore.md +208 -0
- package/skills/references/bnbagent-studio-use-azure-foundry.md +164 -0
- package/skills/references/bnbagent-studio-use-bnb-trial.md +92 -0
- package/skills/references/bnbagent-studio-using-altana-wallet.md +68 -0
- package/skills/references/bnbagent-studio-using-twak-wallet.md +260 -0
- 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
|
+
}
|