@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.
Files changed (36) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +2 -2
  3. package/dist/_agentcoreName-DZDWEYD3.js +0 -0
  4. package/dist/_twak-4XF4H5PL.js +0 -0
  5. package/dist/bag.js +577 -370
  6. package/dist/chunk-RO726HJG.js +0 -0
  7. package/dist/{chunk-YFEM4564.js → chunk-TTPOH453.js} +79 -37
  8. package/dist/chunk-U7IDQ3K5.js +0 -0
  9. package/dist/{deployCli-NJFCWBSF.js → deployCli-K55GXDVO.js} +1 -1
  10. package/package.json +11 -12
  11. package/recipes/runtimes/agentcore/code/{{PKG}}/Dockerfile.tmpl +20 -21
  12. package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +36 -0
  13. package/recipes/runtimes/agentcore/code/{{PKG}}/unifiedMain.ts.tmpl +620 -0
  14. package/recipes/runtimes/agentcore/recipe.toml +3 -3
  15. package/recipes/runtimes/azure-foundry/code/{{PKG}}/Dockerfile.tmpl +25 -23
  16. package/recipes/runtimes/azure-foundry/code/{{PKG}}/agentCard.ts.tmpl +16 -12
  17. package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +72 -393
  18. package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +160 -43
  19. package/recipes/runtimes/azure-foundry/code/{{PKG}}/sellerCore.ts.tmpl +504 -0
  20. package/recipes/runtimes/azure-foundry/code/{{PKG}}/unifiedMain.ts.tmpl +620 -0
  21. package/recipes/runtimes/azure-foundry/recipe.toml +19 -11
  22. package/recipes/x402-buyer/code/{{PKG}}/x402Buyer.ts.tmpl +6 -4
  23. package/skills/bnbagent-studio.md +2 -2
  24. package/skills/references/bnbagent-studio-adding-to-project.md +1 -1
  25. package/skills/references/bnbagent-studio-buying-from-bazaar.md +1 -1
  26. package/skills/references/bnbagent-studio-operating.md +4 -4
  27. package/skills/references/bnbagent-studio-scaffolding-agent.md +4 -4
  28. package/skills/references/bnbagent-studio-selling-via-8183.md +3 -3
  29. package/skills/references/bnbagent-studio-selling-via-b402.md +2 -2
  30. package/skills/references/bnbagent-studio-use-aws-agentcore.md +1 -1
  31. package/skills/references/bnbagent-studio-use-azure-foundry.md +3 -3
  32. package/skills/references/bnbagent-studio-use-bnb-trial.md +1 -1
  33. package/skills/references/bnbagent-studio-wiring-llm-tools.md +3 -3
  34. package/recipes/runtimes/agentcore/code/{{PKG}}/main.ts.tmpl +0 -347
  35. package/recipes/runtimes/azure-foundry/code/{{PKG}}/foundryMain.ts.tmpl +0 -422
  36. package/recipes/runtimes/azure-foundry/code/{{PKG}}/main.ts.tmpl +0 -196
@@ -1,35 +1,33 @@
1
- # Azure AI Foundry Hosted Agent image — SHIPPED BY `bag init`.
2
- # NOTE: deliberately NO `# syntax=docker/dockerfile:1` directive AND no BuildKit-only
3
- # instruction flags (`FROM --platform=…`, `RUN --mount=…`) — the file stays
4
- # classic-builder-compatible so any builder (a "classic" builder rejects
5
- # `FROM --platform=…` with "unable to understand line FROM --platform=…") can
6
- # process it. The target arch is pinned OUTSIDE the Dockerfile instead: the
7
- # pinned bnbagent-deploy (which `bag deploy --provider azure` delegates all cloud
8
- # execution to) builds this Dockerfile LOCALLY with Docker for linux/amd64
9
- # (`--platform` on the build command) and pushes the image to the
10
- # auto-provisioned Azure Container Registry; Foundry Agent Service pulls +
11
- # runs it, assigning a dedicated agent identity + endpoint.
1
+ # Agent image — SHIPPED BY `bag init`, ONE Dockerfile for BOTH deploy clouds.
12
2
  #
13
- # Why a custom image: azure-foundry deploys are container-only (the deploy
14
- # provider rejects Node zip), and a twak wallet additionally shells out to the
15
- # `twak` CLI (npm @trustwallet/cli) for every signing / on-chain intent, so
16
- # the image must carry it globally.
17
- FROM docker.io/library/node:22-slim
3
+ # Why a custom image: the managed Node runtimes ship only the project's own
4
+ # bundle, and every signing / on-chain intent for a twak wallet shells out to
5
+ # the `twak` CLI (npm @trustwallet/cli), which must be installed globally in
6
+ # the image. `bag deploy` hands this file to the pinned bnbagent-deploy,
7
+ # which builds the image locally with Docker and pushes it to the target
8
+ # cloud's registry (ECR for AgentCore, ACR for Azure Foundry).
9
+ #
10
+ # Arch is NOT pinned here: each deploy provider pins it on the build command
11
+ # (`--platform linux/arm64` for AgentCore, `--platform linux/amd64` for
12
+ # Foundry), so the same Dockerfile cross-builds for either cloud. Keep this
13
+ # file classic-builder compatible: no `# syntax=` directive and no
14
+ # BuildKit-only instructions (`RUN --mount=...`), so a plain `docker build`
15
+ # works everywhere.
16
+ FROM public.ecr.aws/docker/library/node:22-slim
18
17
 
19
- # The pinned twak CLI + pnpm.
18
+ # The pinned twak CLI.
20
19
  #
21
20
  # ARCH WARNING: `npm install` MUST run with this image's own node (as below).
22
21
  # Installing on the host (or any other arch) and COPYing node_modules in
23
22
  # breaks at runtime: twak's `@napi-rs/keyring` native binding is selected at
24
- # install time per-arch (known npm optional-deps bug) — a cross-arch-installed
25
- # tree crashes at runtime. Multi-arch images must install per-arch too.
23
+ # install time per-arch (known npm optional-deps bug) — an x64-installed tree
24
+ # crashes under arm64 node. Multi-arch images must install per-arch too.
26
25
  RUN npm install -g @trustwallet/cli@{{TWAK_CLI_VERSION}} pnpm@{{PNPM_VERSION}} \
27
26
  && npm cache clean --force
28
27
 
29
28
  WORKDIR /app
30
29
  # Install deps first (layer cache), then build. The lockfile is copied when
31
- # present; a project without one falls back to a plain install. (No BuildKit
32
- # cache mounts here — see the classic-builder note above.)
30
+ # present; a project without one falls back to a plain install.
33
31
  COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
34
32
  COPY vendor ./vendor
35
33
  RUN pnpm install
@@ -40,8 +38,12 @@ RUN pnpm build
40
38
  # Runtime deps only in the final tree (drops typescript etc.).
41
39
  RUN pnpm prune --prod
42
40
 
43
- # {{CONTAINER_PORT}} / {{ENTRYPOINT}} are rendered by `bag init` from the
44
- # provider contract (Foundry uses 8088 + dist/foundryMain.js).
41
+ # Hosted-runtime port contracts (the unified entrypoint binds BOTH 9000 and
42
+ # 8088 itself; AGENT_PORT only prepends a primary override):
43
+ # AgentCore A2A → 0.0.0.0:9000 at / MCP → 0.0.0.0:8000/mcp
44
+ # Foundry invocations/responses → 0.0.0.0:8088
45
+ # `bag init` renders {{CONTAINER_PORT}} / {{ENTRYPOINT}} from the selected
46
+ # protocol. ({{ENTRYPOINT}} is the BUILT entrypoint, e.g. dist/unifiedMain.js.)
45
47
  ENV AGENT_PORT={{CONTAINER_PORT}}
46
48
  EXPOSE {{CONTAINER_PORT}}
47
49
  CMD ["node", "{{ENTRYPOINT}}"]
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * A2A AgentCard — the seller agent's outward, discoverable identity.
3
3
  *
4
- * Built by `main.ts` and served at `/.well-known/agent-card.json`. On
5
- * AgentCore, `$AGENTCORE_RUNTIME_URL` overrides `card.url` at boot;
6
- * azure-foundry/local runs keep the client-routable local placeholder.
4
+ * Built by `main.ts` and served at `/.well-known/agent-card.json`. When
5
+ * deployed, `main.ts` overwrites `card.url` at boot with the deployed
6
+ * AgentCore runtime URL (`$AGENTCORE_RUNTIME_URL`), so the `url` here is only
7
+ * a local-dev placeholder.
7
8
  *
8
9
  * The card advertises exactly two skills — `negotiate` and `notify_funded` —
9
- * and the OAuth2 (Cognito) security scheme buyers must satisfy when deployed
10
- * behind AgentCore: A2A endpoints require an inbound OAuth2 bearer (there is
11
- * no anonymous mode).
10
+ * and the OAuth2 (Cognito) security scheme buyers must satisfy: AgentCore A2A
11
+ * endpoints require an inbound OAuth2 bearer (there is no anonymous mode).
12
12
  * The token URL + scope come from the Cognito user pool
13
13
  * `bag deploy provision-cognito` creates (env `OAUTH_TOKEN_URL` /
14
14
  * `OAUTH_SCOPE`, injected at deploy); the runtime's inbound JWT authorizer
@@ -93,8 +93,10 @@ function oauth2Scheme(): SecurityScheme | null {
93
93
  };
94
94
  }
95
95
 
96
- /** Build the A2A AgentCard advertising the two seller skills (+ OAuth2 if set). */
97
- export function buildAgentCard(): AgentCard {
96
+ /** Build the A2A AgentCard, gating ERC-8183 skills on the configured rail. */
97
+ export function buildAgentCard(
98
+ opts: { commerceSkills?: boolean } = {},
99
+ ): AgentCard {
98
100
  const name = agentName();
99
101
  const extra: Partial<AgentCard> = {};
100
102
  const scheme = oauth2Scheme();
@@ -106,13 +108,14 @@ export function buildAgentCard(): AgentCard {
106
108
  return {
107
109
  name,
108
110
  description: `ERC-8183 seller agent (${name}) — negotiate + notify_funded over A2A.`,
109
- // $AGENTCORE_RUNTIME_URL wins when deployed behind AgentCore.
111
+ // main.ts overwrites this with $AGENTCORE_RUNTIME_URL at boot.
110
112
  // Local-dev fallback: a client-routable localhost URL (not the 0.0.0.0
111
113
  // bind address). Host via AGENT_HOST (default localhost); port via the
112
- // same PORT → AGENT_PORT → 9000 resolution main.ts serves on.
114
+ // same AGENT_PORT → 9000 resolution main.ts serves on. Do not honor the
115
+ // AgentCore HTTP $PORT=8080 convention for this A2A runtime.
113
116
  url:
114
117
  process.env.AGENTCORE_RUNTIME_URL ??
115
- `http://${process.env.AGENT_HOST ?? "localhost"}:${process.env.PORT || process.env.AGENT_PORT || "9000"}/`,
118
+ `http://${process.env.AGENT_HOST ?? "localhost"}:${process.env.AGENT_PORT || "9000"}/`,
116
119
  version: "1.0.0",
117
120
  protocolVersion: "0.3.0",
118
121
  preferredTransport: "JSONRPC",
@@ -125,7 +128,8 @@ export function buildAgentCard(): AgentCard {
125
128
  capabilities: { streaming: false },
126
129
  defaultInputModes: ["application/json"],
127
130
  defaultOutputModes: ["application/json"],
128
- skills: [NEGOTIATE, NOTIFY_FUNDED],
131
+ skills:
132
+ opts.commerceSkills === false ? [] : [NEGOTIATE, NOTIFY_FUNDED],
129
133
  ...extra,
130
134
  };
131
135
  }
@@ -1,38 +1,38 @@
1
1
  /**
2
- * A2A executor — the seller agent's outward surface (two fixed-code skills).
2
+ * A2A executor — the seller agent's outward A2A surface (two fixed-code
3
+ * skills).
3
4
  *
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`:
5
+ * The agent serves A2A directly (an `@a2a-js/sdk` express app on the
6
+ * AgentCore A2A runtime contract). This module is ONLY the a2a wire:
7
+ * {@link SellerAgentExecutor} inherits all of the seller logic +
8
+ * background-delivery machinery from `sellerCore.ts` `SellerCore` (which
9
+ * imports nothing from `@a2a-js/sdk`) and adds the a2a-specific
10
+ * {@link SellerAgentExecutor.execute} / {@link SellerAgentExecutor.cancelTask}
11
+ * entrypoints plus the request/response wire helpers. `execute` reads the
12
+ * inbound message's data part and dispatches on its `skill`:
9
13
  *
10
- * negotiate → `signing.signQuote` (rule-based price clamp + EIP-191 sign)
11
- * notify_funded → `signing.verifySignedJob` (fast on-chain gate) → ACK at
14
+ * negotiate → `SellerCore.negotiate` (rule-based price clamp + EIP-191 sign)
15
+ * notify_funded → `SellerCore.notifyFunded` (fast on-chain gate) → ACK at
12
16
  * once, then in the BACKGROUND: LLM work → `signing.submitResult`
13
17
  *
14
18
  * `notify_funded` is the buyer's "I funded job X — please deliver"
15
19
  * 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
20
+ * caller: the core verifies the funded job synchronously (a couple of
21
+ * eth_calls) to ACK accepted/rejected, then runs the slow LLM work + on-chain
22
+ * `submit` in a background task and replies immediately. The buyer reads the
19
23
  * deliverable back from the CHAIN (SUBMITTED / `getDeliverableUrl`) — the
20
24
  * 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
25
+ * `isBusy` (from `SellerCore`) reports busy, which `main.ts` feeds to
26
+ * AgentCore's `/ping` as `HEALTHY_BUSY` so the scale-to-zero runtime stays
23
27
  * warm until the work lands (within the session max-lifetime).
24
28
  *
25
29
  * ALL signing is FIXED code in `signing.ts` — NEVER an LLM-callable tool
26
30
  * (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.
31
+ * `runWork` hook). See `sellerCore.ts` for the negotiate / notifyFunded /
32
+ * sweep logic.
33
33
  *
34
- * You own this file — specialise the work hook / dispatch, but keep signing
35
- * OUT of the LLM tool list.
34
+ * You own this file — specialise the work hook / dispatch in `sellerCore.ts`,
35
+ * but keep signing OUT of the LLM tool list.
36
36
  */
37
37
 
38
38
  import { randomUUID } from "node:crypto";
@@ -43,140 +43,35 @@ import {
43
43
  type ExecutionEventBus,
44
44
  type RequestContext,
45
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";
46
+ import { SellerCore } from "./sellerCore.js";
50
47
 
51
48
  const log = {
52
- info: (msg: string) => console.log(`[seller-agent.a2a] ${msg}`),
53
- warn: (msg: string) => console.warn(`[seller-agent.a2a] WARNING ${msg}`),
54
49
  error: (msg: string, e?: unknown) =>
55
50
  console.error(`[seller-agent.a2a] ERROR ${msg}`, e ?? ""),
56
51
  };
57
52
 
58
53
  /**
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.
54
+ * ERC-8183 seller A2A executor: the a2a wire over `SellerCore`.
114
55
  *
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.
56
+ * All seller logic (negotiate, notifyFunded, background delivery, `isBusy`,
57
+ * the constructor bookkeeping, the `runWork` hook) lives in
58
+ * `sellerCore.ts` `SellerCore`; this class adds only the A2A entrypoints and
59
+ * request/response wire helpers.
118
60
  *
119
61
  * The agent exposes ONLY the two paid, structured skills — there is no
120
62
  * free-form chat skill. A plain text message (no `{"skill": ...}` DataPart)
121
63
  * is rejected: negotiate / notify_funded always need a structured DataPart,
122
64
  * so prose never triggers an LLM call or a paid action.
123
65
  */
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
-
66
+ export class SellerAgentExecutor extends SellerCore implements AgentExecutor {
167
67
  /**
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.
68
+ * Text-carrier entrypoint (Foundry invocations / responses SkillRouter).
176
69
  *
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).
70
+ * Same skill switch as {@link execute}, but NEVER throws: on a text
71
+ * carrier there is no JSON-RPC error channel, so a fault is returned as an
72
+ * `{"error": ...}` dict and the caller can always reply. The A2A path
73
+ * keeps its own switch below because its fault semantics differ (faults
74
+ * become JSON-RPC -32603 via A2AError).
180
75
  */
181
76
  async dispatch(
182
77
  data: Record<string, unknown>,
@@ -205,13 +100,45 @@ export class SellerAgentExecutor implements AgentExecutor {
205
100
  }
206
101
  }
207
102
 
208
- // ── A2A entrypoints ─────────────────────────────────────────────────────
209
-
103
+ // ── A2A entrypoints ───────────────────────────────────────────────────────
210
104
  execute = async (
211
105
  context: RequestContext,
212
106
  eventBus: ExecutionEventBus,
213
107
  ): Promise<void> => {
214
- const result = await this.dispatch(inbound(context));
108
+ const data = inbound(context);
109
+ const skill = data.skill;
110
+ let result: Record<string, unknown>;
111
+ try {
112
+ if (skill === "negotiate") {
113
+ result = await this.negotiate(data);
114
+ } else if (skill === "notify_funded") {
115
+ result = await this.notifyFunded(data);
116
+ } else {
117
+ // Includes a plain text message (no DataPart → skill is undefined):
118
+ // the seller has no free-form skill, so prose is rejected here.
119
+ result = {
120
+ error: `unknown skill: ${JSON.stringify(skill)}`,
121
+ skills: this.skills(),
122
+ };
123
+ if (skill === undefined) {
124
+ // Most common cause: the caller put the JSON envelope in a
125
+ // "text" part. Structured skill calls must ride in a DataPart.
126
+ result.hint =
127
+ 'send the skill envelope as an A2A data part: parts:[{"kind":"data","data":{"skill":"negotiate",...}}]';
128
+ }
129
+ }
130
+ } catch (e) {
131
+ // A genuine internal fault is surfaced as a JSON-RPC error, NOT masked
132
+ // as a successful result. Throwing `A2AError.internalError` is caught
133
+ // by @a2a-js/sdk's request handler and serialized to a proper -32603
134
+ // carrying the request id. CLASSIFIED business outcomes are
135
+ // returned as a result above (peer of the MCP runtime: faults →
136
+ // isError, business outcomes → result).
137
+ log.error(`skill ${JSON.stringify(skill)} failed`, e);
138
+ const name = e instanceof Error ? e.constructor.name : "Error";
139
+ const msg = e instanceof Error ? e.message : String(e);
140
+ throw A2AError.internalError(`${name}: ${msg}`);
141
+ }
215
142
  reply(eventBus, context, result);
216
143
  };
217
144
 
@@ -221,251 +148,11 @@ export class SellerAgentExecutor implements AgentExecutor {
221
148
  ): Promise<void> => {
222
149
  // negotiate is synchronous; notify_funded acks then delivers on-chain in
223
150
  // the background — once submitted it is anchored on-chain and cannot be
224
- // cancelled via A2A. Nothing to cancel here.
151
+ // cancelled via A2A. Nothing to cancel here. (@a2a-js/sdk hands cancel
152
+ // only a taskId — no message to reply to — so this surfaces as the
153
+ // standard JSON-RPC unsupported-operation error.)
225
154
  throw A2AError.unsupportedOperation("cancel");
226
155
  };
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
156
  }
470
157
 
471
158
  // ── wire helpers ──────────────────────────────────────────────────────────────
@@ -494,11 +181,3 @@ function reply(
494
181
  eventBus.publish(message);
495
182
  eventBus.finished();
496
183
  }
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
- }