@bnbagent/studio-cli 0.0.10 → 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 +317 -124
- 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
|
@@ -1,35 +1,33 @@
|
|
|
1
|
-
#
|
|
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:
|
|
14
|
-
#
|
|
15
|
-
# `twak` CLI (npm @trustwallet/cli)
|
|
16
|
-
# the image
|
|
17
|
-
|
|
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
|
|
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) —
|
|
25
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
44
|
-
#
|
|
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`.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
10
|
-
*
|
|
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
|
|
97
|
-
export function buildAgentCard(
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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:
|
|
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
|
|
2
|
+
* A2A executor — the seller agent's outward A2A surface (two fixed-code
|
|
3
|
+
* skills).
|
|
3
4
|
*
|
|
4
|
-
* The agent serves A2A directly
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* and
|
|
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 → `
|
|
11
|
-
* notify_funded → `
|
|
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:
|
|
17
|
-
* to ACK accepted/rejected, then runs the slow LLM work + on-chain
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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).
|
|
28
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
|
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
|
-
}
|