@bnbagent/studio-cli 0.0.9 → 0.0.11-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +2 -2
- package/dist/_agentcoreName-DZDWEYD3.js +0 -0
- package/dist/_twak-4XF4H5PL.js +0 -0
- package/dist/bag.js +577 -370
- package/dist/chunk-RO726HJG.js +0 -0
- package/dist/{chunk-YFEM4564.js → chunk-TTPOH453.js} +79 -37
- package/dist/chunk-U7IDQ3K5.js +0 -0
- package/dist/{deployCli-NJFCWBSF.js → deployCli-K55GXDVO.js} +1 -1
- package/package.json +11 -12
- package/recipes/runtimes/agentcore/code/{{PKG}}/Dockerfile.tmpl +20 -21
- package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +36 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/unifiedMain.ts.tmpl +620 -0
- package/recipes/runtimes/agentcore/recipe.toml +3 -3
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/Dockerfile.tmpl +25 -23
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/agentCard.ts.tmpl +16 -12
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +72 -393
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +160 -43
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/sellerCore.ts.tmpl +504 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/unifiedMain.ts.tmpl +620 -0
- package/recipes/runtimes/azure-foundry/recipe.toml +19 -11
- package/recipes/x402-buyer/code/{{PKG}}/x402Buyer.ts.tmpl +6 -4
- package/skills/bnbagent-studio.md +2 -2
- package/skills/references/bnbagent-studio-adding-to-project.md +1 -1
- package/skills/references/bnbagent-studio-buying-from-bazaar.md +1 -1
- package/skills/references/bnbagent-studio-operating.md +4 -4
- package/skills/references/bnbagent-studio-scaffolding-agent.md +4 -4
- package/skills/references/bnbagent-studio-selling-via-8183.md +3 -3
- package/skills/references/bnbagent-studio-selling-via-b402.md +2 -2
- package/skills/references/bnbagent-studio-use-aws-agentcore.md +1 -1
- package/skills/references/bnbagent-studio-use-azure-foundry.md +3 -3
- package/skills/references/bnbagent-studio-use-bnb-trial.md +1 -1
- package/skills/references/bnbagent-studio-wiring-llm-tools.md +3 -3
- package/recipes/runtimes/agentcore/code/{{PKG}}/main.ts.tmpl +0 -347
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/foundryMain.ts.tmpl +0 -422
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/main.ts.tmpl +0 -196
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified A2A seller agent entrypoint — ONE file for BOTH deploy runtimes.
|
|
3
|
+
* Generated by `bag recipe code agent`.
|
|
4
|
+
*
|
|
5
|
+
* This is the VALUABLE agent AND the SOLE key-holder/signer. The same
|
|
6
|
+
* process satisfies both hosted-agent container contracts at once:
|
|
7
|
+
*
|
|
8
|
+
* - **AWS Bedrock AgentCore** (`--provider aws`): `@a2a-js/sdk` express app
|
|
9
|
+
* — agent card at `/.well-known/agent-card.json` + JSON-RPC `message/send`
|
|
10
|
+
* — plus `GET /ping` for the AgentCore liveness contract (contract port
|
|
11
|
+
* 9000).
|
|
12
|
+
* - **Azure AI Foundry Hosted Agents** (`--provider azure`): pass-through
|
|
13
|
+
* `POST /invocations` + `POST /responses` (Foundry's Responses protocol
|
|
14
|
+
* for managed incoming A2A) plus `GET /readiness` (contract port 8088).
|
|
15
|
+
*
|
|
16
|
+
* The server listens on BOTH contract ports (9000 and 8088; `AGENT_PORT`
|
|
17
|
+
* prepends a local override), so the SAME image deploys to either cloud with
|
|
18
|
+
* zero source changes — the per-cloud build only pins the CPU arch.
|
|
19
|
+
*
|
|
20
|
+
* A2A skills (executor.ts, shared by every carrier):
|
|
21
|
+
*
|
|
22
|
+
* negotiate → read the FIXED list price → CLAMP to [min,max] → EIP-191 SIGN
|
|
23
|
+
* the offer (no LLM, no tools) → return the signed offer (or reject)
|
|
24
|
+
* notify_funded → re-verify the funded job on-chain (fast) → ACK accepted at once,
|
|
25
|
+
* then in the BACKGROUND: LLM work → manifest → storage →
|
|
26
|
+
* submitResult (SIGN + broadcast). The buyer polls the chain for
|
|
27
|
+
* the deliverable. Each notify also sweeps other FUNDED jobs
|
|
28
|
+
* (buyer-push fallback). While background work is in flight the
|
|
29
|
+
* `/ping` handler reports HEALTHY_BUSY so AgentCore keeps the
|
|
30
|
+
* scale-to-zero runtime warm until it lands.
|
|
31
|
+
*
|
|
32
|
+
* On Foundry the skills ride as a JSON text envelope (`{"skill": ...}`)
|
|
33
|
+
* because Foundry's incoming A2A is text-modality only — {@link SkillRouter}
|
|
34
|
+
* parses it and dispatches through the SAME `SellerAgentExecutor.dispatch`.
|
|
35
|
+
*
|
|
36
|
+
* ## How x402 buyers reach /x402 on either runtime
|
|
37
|
+
*
|
|
38
|
+
* Neither hosted-agent gateway allows anonymous ingress, and both strip or
|
|
39
|
+
* gate the buyer's half of the HTTP exchange — so the anonymous buyer talks
|
|
40
|
+
* to an external gateway that wraps each request in `http-envelope-v1`
|
|
41
|
+
* (outer HTTP always 200) and forwards it with the runtime's own auth.
|
|
42
|
+
* {@link createEnvelopeMiddleware} unwraps the tunnel onto the in-process
|
|
43
|
+
* {@link X402Seller} below. See docs/guides/self-hosted-x402-gateway.md
|
|
44
|
+
* (AWS) and self-hosted-x402-gateway-azure.md (Azure).
|
|
45
|
+
*
|
|
46
|
+
* Secrets: on AgentCore, `loadRuntimeSecrets()` pulls the Secrets Manager
|
|
47
|
+
* bundle named by BNBAGENT_RUNTIME_SECRET_ID; on Azure the provider-managed
|
|
48
|
+
* Foundry CustomKeys connection injects the same variables before the
|
|
49
|
+
* process starts (the loader no-ops when the pointer is absent).
|
|
50
|
+
*
|
|
51
|
+
* ## Boundaries (do NOT cross — they are the whole point)
|
|
52
|
+
*
|
|
53
|
+
* - The agent does ALL deterministic SIGNING (quote-sign + submit + settle +
|
|
54
|
+
* automatic Pieverse LLM-credit auto-renew). ALL signing is FIXED code in
|
|
55
|
+
* `signing.ts` — NEVER an LLM-callable tool (money never in the LLM).
|
|
56
|
+
* - The price is a FIXED list price from studio.toml (clamped before
|
|
57
|
+
* signing) — the LLM never prices; it only PRODUCES the work text in the
|
|
58
|
+
* delivery step.
|
|
59
|
+
* - Chain access for the LLM is READ-ONLY tools only (`tools.ts`).
|
|
60
|
+
* - `settle` (claim payment after the dispute window) is operator-driven —
|
|
61
|
+
* run `bag erc8183 settle <job_id>`; it is deliberately NOT an A2A skill.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
65
|
+
import { pathToFileURL } from "node:url";
|
|
66
|
+
import {
|
|
67
|
+
GetSecretValueCommand,
|
|
68
|
+
SecretsManagerClient,
|
|
69
|
+
} from "@aws-sdk/client-secrets-manager";
|
|
70
|
+
import { DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
|
|
71
|
+
import {
|
|
72
|
+
agentCardHandler,
|
|
73
|
+
jsonRpcHandler,
|
|
74
|
+
UserBuilder,
|
|
75
|
+
} from "@a2a-js/sdk/server/express";
|
|
76
|
+
import {
|
|
77
|
+
loadStudioToml,
|
|
78
|
+
type TomlTable,
|
|
79
|
+
} from "@bnbagent/studio-runtime/config";
|
|
80
|
+
import {
|
|
81
|
+
ensureAltanaSessionLoaded,
|
|
82
|
+
ensureKeystoreMaterialized,
|
|
83
|
+
ensureTwakMaterialized,
|
|
84
|
+
getWallet,
|
|
85
|
+
} from "@bnbagent/studio-runtime/wallet";
|
|
86
|
+
import {
|
|
87
|
+
createEnvelopeMiddleware,
|
|
88
|
+
X402_SELL_PATH,
|
|
89
|
+
type X402HttpRequest,
|
|
90
|
+
type X402RunWork,
|
|
91
|
+
X402Seller,
|
|
92
|
+
} from "@bnbagent/studio-runtime/x402";
|
|
93
|
+
import { generateText, stepCountIs } from "ai";
|
|
94
|
+
import express from "express";
|
|
95
|
+
import { buildAgentCard } from "./agentCard.js";
|
|
96
|
+
import { SellerAgentExecutor } from "./executor.js";
|
|
97
|
+
import { buildModel } from "./model.js";
|
|
98
|
+
import type { RunWork } from "./sellerCore.js";
|
|
99
|
+
import { LLM_READ_TOOLS } from "./tools.js";
|
|
100
|
+
|
|
101
|
+
const APP_NAME = "agent";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Deliverable `generator` label: this seller's own name, read from
|
|
105
|
+
* studio.toml `[project].name` (minus the `-agent` suffix) so each delivered
|
|
106
|
+
* manifest is self-identifying. Best-effort — falls back to `APP_NAME` if
|
|
107
|
+
* the config can't be read.
|
|
108
|
+
*/
|
|
109
|
+
function generatorTag(): string {
|
|
110
|
+
let name = "";
|
|
111
|
+
try {
|
|
112
|
+
const cfg = loadStudioToml();
|
|
113
|
+
name = String(((cfg.project ?? {}) as Record<string, unknown>).name ?? "");
|
|
114
|
+
} catch {
|
|
115
|
+
// a metadata label must never break delivery
|
|
116
|
+
return APP_NAME;
|
|
117
|
+
}
|
|
118
|
+
return name.endsWith("-agent")
|
|
119
|
+
? name.slice(0, -"-agent".length)
|
|
120
|
+
: name || APP_NAME;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Runtime secrets ───────────────────────────────────────────────────────────
|
|
124
|
+
// Keep plaintext secrets OUT of agentcore.json. When BNBAGENT_RUNTIME_SECRET_ID
|
|
125
|
+
// is set (deployed AgentCore runtime), pull a JSON {ENV_NAME: value} blob from
|
|
126
|
+
// AWS Secrets Manager into the process env BEFORE anything reads it (keystore
|
|
127
|
+
// unlock, provider key, buildModel, Cognito OAuth env). No-op locally (where
|
|
128
|
+
// .env.local already populated the environment) and on Azure Foundry (where
|
|
129
|
+
// the CustomKeys connection injects the variables before the process starts).
|
|
130
|
+
// In a deployed AgentCore runtime the managed secret bundle is authoritative
|
|
131
|
+
// and replaces any stale spec-level value left by an earlier runtime revision.
|
|
132
|
+
async function loadRuntimeSecrets(): Promise<void> {
|
|
133
|
+
const secretId = process.env.BNBAGENT_RUNTIME_SECRET_ID;
|
|
134
|
+
if (!secretId) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const resp = await new SecretsManagerClient({}).send(
|
|
138
|
+
new GetSecretValueCommand({ SecretId: secretId }),
|
|
139
|
+
);
|
|
140
|
+
const bundle = JSON.parse(resp.SecretString ?? "{}") as Record<
|
|
141
|
+
string,
|
|
142
|
+
unknown
|
|
143
|
+
>;
|
|
144
|
+
for (const [key, value] of Object.entries(bundle)) {
|
|
145
|
+
process.env[key] = String(value);
|
|
146
|
+
}
|
|
147
|
+
const pieverseKey = process.env.PIEVERSE_LLM_API_KEY;
|
|
148
|
+
if (pieverseKey) {
|
|
149
|
+
const fingerprint = createHash("sha256")
|
|
150
|
+
.update(pieverseKey, "utf-8")
|
|
151
|
+
.digest("hex")
|
|
152
|
+
.slice(0, 12);
|
|
153
|
+
console.info(
|
|
154
|
+
`[runtime-secrets] PIEVERSE_LLM_API_KEY source=secretsmanager sha256=${fingerprint}…`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** studio.toml `[network].default` (best-effort; used by the funded sweep). */
|
|
160
|
+
function defaultNetwork(): string {
|
|
161
|
+
try {
|
|
162
|
+
const cfg = loadStudioToml();
|
|
163
|
+
return String(
|
|
164
|
+
((cfg.network ?? {}) as Record<string, unknown>).default ?? "bsc-testnet",
|
|
165
|
+
);
|
|
166
|
+
} catch {
|
|
167
|
+
return "bsc-testnet";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── One-shot LLM helper (the executor's delivery work hook) ──────────────────
|
|
172
|
+
// LLM credit auto-renew (Pieverse path): `buildModel()` (in model.ts) returns
|
|
173
|
+
// a model wrapped with a middleware that auto-tops up the active Pieverse key
|
|
174
|
+
// before each generate call when [llm.auto_renew] is enabled. That top-up is
|
|
175
|
+
// the ONLY automatic signing path outside signing.ts — it is budget-gated and
|
|
176
|
+
// is NOT an LLM tool. It rides transparently into the delivery step.
|
|
177
|
+
//
|
|
178
|
+
// The LLM runs only in an authorized value step: verified ERC-8183 delivery
|
|
179
|
+
// or x402 work after its payment/free gate. `negotiate` is rule-based and
|
|
180
|
+
// never touches the LLM. The read-only chain tools are
|
|
181
|
+
// attached so the work can read on-chain context if it needs to — drop them
|
|
182
|
+
// from `tools.ts` if your work doesn't read chain. Signing / settle are NEVER
|
|
183
|
+
// tools — they are fixed code in signing.ts, triggered by the A2A skills,
|
|
184
|
+
// never callable by the LLM. (The one deliberate exception: the x402-buyer
|
|
185
|
+
// recipe's PAID fetch tools — see the `tools:` note below — the LLM picks the
|
|
186
|
+
// URL, but who gets paid and the per-call/daily caps stay locked in
|
|
187
|
+
// studio.toml.)
|
|
188
|
+
export function buildRunWork(): RunWork {
|
|
189
|
+
// The model is resolved LAZILY on first delivery, not at boot: a seller
|
|
190
|
+
// with no provider key yet must still serve negotiate (which never calls
|
|
191
|
+
// the LLM) — missing-key errors surface at notify_funded delivery time.
|
|
192
|
+
let model: ReturnType<typeof buildModel> | undefined;
|
|
193
|
+
return async (prompt, { abortSignal }) => {
|
|
194
|
+
model ??= buildModel(); // managed model with the auto-renew hook (delivery only)
|
|
195
|
+
const result = await generateText({
|
|
196
|
+
model,
|
|
197
|
+
system:
|
|
198
|
+
"You are a seller agent. The runtime has already authorized this task " +
|
|
199
|
+
"through its configured commerce rail. Complete the user's task now; " +
|
|
200
|
+
"do not ask for a job ID or additional payment. " +
|
|
201
|
+
"Be concrete and concise. Use the read-only chain tools when on-chain " +
|
|
202
|
+
"context helps. If a paid-data tool such as `buy_with_x402` is available " +
|
|
203
|
+
"to you, USE IT to fetch the data a task needs — those merchants (e.g. " +
|
|
204
|
+
"CoinMarketCap) charge via on-chain wallet payment, NOT an API key; never " +
|
|
205
|
+
"reply that you cannot complete the task for lack of an API key.",
|
|
206
|
+
prompt,
|
|
207
|
+
// LLM_READ_TOOLS = read-only chain tools (wallet, balances,
|
|
208
|
+
// ERC-8004/8183 queries). Edit `tools.ts` to add/remove. These are
|
|
209
|
+
// READ-ONLY — the agent never signs via a tool; all signing is in
|
|
210
|
+
// signing.ts (fixed code).
|
|
211
|
+
// To let the agent BUY paid data at work time (e.g. CMC market data
|
|
212
|
+
// after `bag x402 trust cmc` + `bag recipe code x402-buyer`), spread
|
|
213
|
+
// the emitted tool set — payee + per-call/daily caps stay locked in
|
|
214
|
+
// studio.toml:
|
|
215
|
+
// import { X402_BUYER_TOOLS } from "./x402Buyer.js";
|
|
216
|
+
// tools: { ...LLM_READ_TOOLS, ...X402_BUYER_TOOLS },
|
|
217
|
+
tools: LLM_READ_TOOLS,
|
|
218
|
+
stopWhen: stepCountIs(8), // bounded tool-call loop, then final text
|
|
219
|
+
abortSignal,
|
|
220
|
+
});
|
|
221
|
+
return result.text.trim();
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function hasErc8183Rail(cfg: TomlTable): boolean {
|
|
226
|
+
const payments = asTable(cfg.payments);
|
|
227
|
+
return asTable(payments?.erc8183) !== null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function asTable(value: unknown): TomlTable | null {
|
|
231
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
232
|
+
? (value as TomlTable)
|
|
233
|
+
: null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function flatHeaders(
|
|
237
|
+
headers: Record<string, string | string[] | undefined>,
|
|
238
|
+
): Record<string, string> {
|
|
239
|
+
const out: Record<string, string> = {};
|
|
240
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
241
|
+
if (typeof value === "string") out[name] = value;
|
|
242
|
+
else if (value !== undefined) out[name] = value[0] ?? "";
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function flatQuery(query: Record<string, unknown>): Record<string, string> {
|
|
248
|
+
const out: Record<string, string> = {};
|
|
249
|
+
for (const [name, value] of Object.entries(query)) {
|
|
250
|
+
if (typeof value === "string") out[name] = value;
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function x402Work(runWork: RunWork): X402RunWork {
|
|
256
|
+
return ({ prompt }) => runWork(prompt, { sessionId: "x402" });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Foundry text-carrier adapters ────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The skill-router shim routing Foundry invocations to seller skills.
|
|
263
|
+
*
|
|
264
|
+
* Foundry's pass-through host receives a JSON body whose `input` is the
|
|
265
|
+
* serialized skill envelope. We parse that text and dispatch it
|
|
266
|
+
* DETERMINISTICALLY through `SellerAgentExecutor.dispatch` (signing.ts fixed
|
|
267
|
+
* code; LLM only inside the notify_funded background work). The result is
|
|
268
|
+
* serialised back to text. A non-envelope message returns the same "unknown
|
|
269
|
+
* skill" reply the @a2a-js path gives — there is no free-form chat skill.
|
|
270
|
+
*/
|
|
271
|
+
export class SkillRouter {
|
|
272
|
+
readonly name: string;
|
|
273
|
+
readonly description =
|
|
274
|
+
"ERC-8183 seller agent (negotiate + notify_funded) over A2A.";
|
|
275
|
+
private readonly executor: SellerAgentExecutor;
|
|
276
|
+
|
|
277
|
+
constructor(executor: SellerAgentExecutor, opts: { name?: string } = {}) {
|
|
278
|
+
this.executor = executor;
|
|
279
|
+
this.name = opts.name ?? "seller_agent";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Route one inbound text turn to the seller skills; returns JSON text. */
|
|
283
|
+
async run(text: string | null | undefined): Promise<string> {
|
|
284
|
+
const envelope = extractEnvelope(text);
|
|
285
|
+
const result =
|
|
286
|
+
envelope === null
|
|
287
|
+
? {
|
|
288
|
+
error:
|
|
289
|
+
'expected a JSON skill envelope, e.g. {"skill": "negotiate", ...}',
|
|
290
|
+
skills: ["negotiate", "notify_funded"],
|
|
291
|
+
}
|
|
292
|
+
: await this.executor.dispatch(envelope);
|
|
293
|
+
return JSON.stringify(result);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Parse inbound user text into a `{"skill": ...}` dict, or null.
|
|
299
|
+
*
|
|
300
|
+
* Foundry's incoming A2A is text-modality only, so the buyer sends the skill
|
|
301
|
+
* envelope as a JSON string. Non-JSON / non-object text → null (the caller
|
|
302
|
+
* replies with a deterministic "unknown skill").
|
|
303
|
+
*/
|
|
304
|
+
export function extractEnvelope(
|
|
305
|
+
text: string | null | undefined,
|
|
306
|
+
): Record<string, unknown> | null {
|
|
307
|
+
if (!text) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
let obj: unknown;
|
|
311
|
+
try {
|
|
312
|
+
obj = JSON.parse(text);
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
return obj !== null && typeof obj === "object" && !Array.isArray(obj)
|
|
317
|
+
? (obj as Record<string, unknown>)
|
|
318
|
+
: null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Extract the latest text turn from an OpenAI Responses request. Foundry's
|
|
322
|
+
* incoming A2A adapter is text-only and projects the caller message here. */
|
|
323
|
+
export function responsesInputText(input: unknown): string | null {
|
|
324
|
+
if (typeof input === "string") {
|
|
325
|
+
return input;
|
|
326
|
+
}
|
|
327
|
+
if (!Array.isArray(input)) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
const texts: string[] = [];
|
|
331
|
+
for (const item of input) {
|
|
332
|
+
if (typeof item === "string") {
|
|
333
|
+
texts.push(item);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (!item || typeof item !== "object") {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const content = (item as Record<string, unknown>).content;
|
|
340
|
+
if (typeof content === "string") {
|
|
341
|
+
texts.push(content);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (!Array.isArray(content)) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
for (const part of content) {
|
|
348
|
+
if (part && typeof part === "object") {
|
|
349
|
+
const text = (part as Record<string, unknown>).text;
|
|
350
|
+
if (typeof text === "string") texts.push(text);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return texts.at(-1) ?? null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function responseId(prefix: string): string {
|
|
358
|
+
return `${prefix}_${randomUUID().replaceAll("-", "")}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function responseEnvelope(text: string, model: string) {
|
|
362
|
+
const responseIdValue = responseId("resp");
|
|
363
|
+
const messageId = responseId("msg");
|
|
364
|
+
const part = { type: "output_text", annotations: [], logprobs: [], text };
|
|
365
|
+
const item = {
|
|
366
|
+
id: messageId,
|
|
367
|
+
type: "message",
|
|
368
|
+
status: "completed",
|
|
369
|
+
role: "assistant",
|
|
370
|
+
content: [part],
|
|
371
|
+
};
|
|
372
|
+
return {
|
|
373
|
+
id: responseIdValue,
|
|
374
|
+
object: "response",
|
|
375
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
376
|
+
status: "completed",
|
|
377
|
+
error: null,
|
|
378
|
+
incomplete_details: null,
|
|
379
|
+
instructions: null,
|
|
380
|
+
max_output_tokens: null,
|
|
381
|
+
model,
|
|
382
|
+
output: [item],
|
|
383
|
+
output_text: text,
|
|
384
|
+
parallel_tool_calls: true,
|
|
385
|
+
previous_response_id: null,
|
|
386
|
+
reasoning: { effort: null, summary: null },
|
|
387
|
+
store: true,
|
|
388
|
+
temperature: 1,
|
|
389
|
+
text: { format: { type: "text" } },
|
|
390
|
+
tool_choice: "auto",
|
|
391
|
+
tools: [],
|
|
392
|
+
top_p: 1,
|
|
393
|
+
truncation: "disabled",
|
|
394
|
+
usage: {
|
|
395
|
+
input_tokens: 0,
|
|
396
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
397
|
+
output_tokens: 0,
|
|
398
|
+
output_tokens_details: { reasoning_tokens: 0 },
|
|
399
|
+
total_tokens: 0,
|
|
400
|
+
},
|
|
401
|
+
metadata: {},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function sendStreamingResponse(
|
|
406
|
+
res: express.Response,
|
|
407
|
+
response: ReturnType<typeof responseEnvelope>,
|
|
408
|
+
): void {
|
|
409
|
+
const item = response.output[0];
|
|
410
|
+
const part = item.content[0];
|
|
411
|
+
const events = [
|
|
412
|
+
{ type: "response.created", sequence_number: 0, response: { ...response, status: "in_progress", output: [] } },
|
|
413
|
+
{ type: "response.output_item.added", sequence_number: 1, output_index: 0, item: { ...item, status: "in_progress", content: [] } },
|
|
414
|
+
{ type: "response.content_part.added", sequence_number: 2, output_index: 0, item_id: item.id, content_index: 0, part: { ...part, text: "" } },
|
|
415
|
+
{ type: "response.output_text.delta", sequence_number: 3, output_index: 0, item_id: item.id, content_index: 0, delta: part.text },
|
|
416
|
+
{ type: "response.output_text.done", sequence_number: 4, output_index: 0, item_id: item.id, content_index: 0, text: part.text },
|
|
417
|
+
{ type: "response.content_part.done", sequence_number: 5, output_index: 0, item_id: item.id, content_index: 0, part },
|
|
418
|
+
{ type: "response.output_item.done", sequence_number: 6, output_index: 0, item },
|
|
419
|
+
{ type: "response.completed", sequence_number: 7, response },
|
|
420
|
+
];
|
|
421
|
+
res.status(200);
|
|
422
|
+
res.setHeader("content-type", "text/event-stream");
|
|
423
|
+
res.setHeader("cache-control", "no-cache");
|
|
424
|
+
for (const event of events) {
|
|
425
|
+
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
426
|
+
}
|
|
427
|
+
res.end("data: [DONE]\n\n");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── serving ───────────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
async function main(): Promise<void> {
|
|
433
|
+
await loadRuntimeSecrets();
|
|
434
|
+
|
|
435
|
+
// Wallet material is NEVER bundled into the deploy artifact. `bag deploy`
|
|
436
|
+
// injects it via the runtime secret channel (Secrets Manager bundle on
|
|
437
|
+
// AgentCore, CustomKeys env on Foundry) and these calls (run once at cold
|
|
438
|
+
// start, before any signing) materialize it on disk. Each is a no-op for
|
|
439
|
+
// the other wallet kind and locally, where the wallet already lives on
|
|
440
|
+
// disk:
|
|
441
|
+
// - evm-local: WALLET_KEYSTORE_JSON → keystore file (unlocked with WALLET_PASSWORD)
|
|
442
|
+
// - twak: TWAK_WALLET_JSON / TWAK_CREDENTIALS_JSON → $TMPDIR/twak-home/.twak
|
|
443
|
+
// (exported as TWAK_HOME_DIR; twak reads TWAK_WALLET_PASSWORD itself)
|
|
444
|
+
ensureKeystoreMaterialized();
|
|
445
|
+
ensureTwakMaterialized();
|
|
446
|
+
await ensureAltanaSessionLoaded();
|
|
447
|
+
|
|
448
|
+
const cfg = loadStudioToml();
|
|
449
|
+
const rails = { erc8183: hasErc8183Rail(cfg) };
|
|
450
|
+
const host = process.env.AGENT_BIND_HOST || "0.0.0.0";
|
|
451
|
+
// Both hosted-agent contract ports are always bound (AgentCore A2A: 9000,
|
|
452
|
+
// Foundry invocations: 8088) so one image serves either cloud. AGENT_PORT
|
|
453
|
+
// (or PORT) prepends a primary override for local runs; the primary port
|
|
454
|
+
// must bind (fatal), the remaining contract ports are best-effort.
|
|
455
|
+
const override = Number(process.env.AGENT_PORT || process.env.PORT || "");
|
|
456
|
+
const ports = [
|
|
457
|
+
...new Set([
|
|
458
|
+
...(Number.isInteger(override) && override > 0 ? [override] : []),
|
|
459
|
+
9000,
|
|
460
|
+
8088,
|
|
461
|
+
]),
|
|
462
|
+
];
|
|
463
|
+
const port = ports[0] as number;
|
|
464
|
+
const runWork = buildRunWork();
|
|
465
|
+
|
|
466
|
+
// The executor backs the seller skills with signing.ts fixed code (NEVER an
|
|
467
|
+
// LLM tool). The SAME executor serves every carrier: @a2a-js JSON-RPC,
|
|
468
|
+
// Foundry invocations (SkillRouter), and Foundry Responses.
|
|
469
|
+
const executor = new SellerAgentExecutor({
|
|
470
|
+
runWork,
|
|
471
|
+
generator: generatorTag(),
|
|
472
|
+
network: defaultNetwork(),
|
|
473
|
+
commerceSkills: rails.erc8183,
|
|
474
|
+
});
|
|
475
|
+
const agentCard = buildAgentCard({ commerceSkills: rails.erc8183 });
|
|
476
|
+
const seller = await X402Seller.create({
|
|
477
|
+
cfg,
|
|
478
|
+
runWork: x402Work(runWork),
|
|
479
|
+
walletAddress: getWallet().address,
|
|
480
|
+
// The 402 challenge's resource URL must be the BUYER-facing address:
|
|
481
|
+
// BNBAGENT_PUBLIC_URL (the operator's gateway base URL, no /x402 suffix —
|
|
482
|
+
// synced with the runtime secrets; Foundry reserves the AGENT_*/FOUNDRY_*
|
|
483
|
+
// env namespaces, hence the BNBAGENT_ prefix; the legacy AGENT_PUBLIC_URL
|
|
484
|
+
// is still honored) or AgentCore's own runtime URL; never a hosted-agent
|
|
485
|
+
// invoke endpoint. Unset, the localhost fallback only suits local runs.
|
|
486
|
+
resourceUrl: `${
|
|
487
|
+
process.env.BNBAGENT_PUBLIC_URL ??
|
|
488
|
+
process.env.AGENT_PUBLIC_URL ??
|
|
489
|
+
process.env.AGENTCORE_RUNTIME_URL ??
|
|
490
|
+
`http://localhost:${port}`
|
|
491
|
+
}${X402_SELL_PATH}`,
|
|
492
|
+
});
|
|
493
|
+
const router = new SkillRouter(executor, { name: generatorTag() });
|
|
494
|
+
|
|
495
|
+
const handler = new DefaultRequestHandler(
|
|
496
|
+
agentCard,
|
|
497
|
+
new InMemoryTaskStore(),
|
|
498
|
+
executor,
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
const app = express();
|
|
502
|
+
|
|
503
|
+
// GET /ping status fed to AgentCore: HEALTHY_BUSY while a background
|
|
504
|
+
// delivery is in flight, else HEALTHY.
|
|
505
|
+
//
|
|
506
|
+
// notify_funded acks immediately and runs the slow work (LLM + on-chain
|
|
507
|
+
// submit) in the background. Reporting HEALTHY_BUSY tells AgentCore the
|
|
508
|
+
// runtime is still working, so the scale-to-zero runtime is NOT reaped on
|
|
509
|
+
// idle before delivery lands (bounded by the session max-lifetime; ≤8h).
|
|
510
|
+
app.get("/ping", (_req, res) => {
|
|
511
|
+
res.json({ status: executor.isBusy() ? "HEALTHY_BUSY" : "HEALTHY" });
|
|
512
|
+
});
|
|
513
|
+
// Required Foundry Hosted Agent health contract. The platform will not
|
|
514
|
+
// create a session or forward invocations until this returns HTTP 200.
|
|
515
|
+
app.get("/readiness", (_req, res) => {
|
|
516
|
+
res.json({ status: "READY" });
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (seller.state !== "disabled") {
|
|
520
|
+
app.all(
|
|
521
|
+
X402_SELL_PATH,
|
|
522
|
+
express.text({ type: "*/*", limit: "1mb" }),
|
|
523
|
+
async (req, res) => {
|
|
524
|
+
const request: X402HttpRequest = {
|
|
525
|
+
method: req.method,
|
|
526
|
+
path: req.path,
|
|
527
|
+
query: flatQuery(req.query),
|
|
528
|
+
headers: flatHeaders(req.headers),
|
|
529
|
+
body:
|
|
530
|
+
typeof req.body === "string"
|
|
531
|
+
? req.body
|
|
532
|
+
: JSON.stringify(req.body ?? ""),
|
|
533
|
+
};
|
|
534
|
+
const out = await seller.handle(request);
|
|
535
|
+
res.status(out.status).set(out.headers).send(out.body);
|
|
536
|
+
},
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
app.use(express.json({ limit: "8mb" }));
|
|
541
|
+
app.use(createEnvelopeMiddleware({ port }));
|
|
542
|
+
|
|
543
|
+
// Foundry Invocations is a pass-through JSON contract. bnbagent-deploy's
|
|
544
|
+
// positional invoke helper normalizes text to {"input":"..."}; advanced
|
|
545
|
+
// callers may send the same shape directly.
|
|
546
|
+
app.post("/invocations", async (req, res) => {
|
|
547
|
+
const input = (req.body ?? {}) as Record<string, unknown>;
|
|
548
|
+
const text = typeof input.input === "string" ? input.input : null;
|
|
549
|
+
res.json({ output: await router.run(text) });
|
|
550
|
+
});
|
|
551
|
+
// The managed platform selects Foundry's Responses protocol so the agent
|
|
552
|
+
// can be exposed through incoming A2A. Keep this adapter small and
|
|
553
|
+
// deterministic: Foundry converts A2A text to Responses input, and the
|
|
554
|
+
// seller result becomes one assistant output_text item.
|
|
555
|
+
app.post("/responses", async (req, res) => {
|
|
556
|
+
const input = (req.body ?? {}) as Record<string, unknown>;
|
|
557
|
+
const output = await router.run(responsesInputText(input.input));
|
|
558
|
+
const response = responseEnvelope(
|
|
559
|
+
output,
|
|
560
|
+
typeof input.model === "string" ? input.model : APP_NAME,
|
|
561
|
+
);
|
|
562
|
+
if (input.stream === true) {
|
|
563
|
+
sendStreamingResponse(res, response);
|
|
564
|
+
} else {
|
|
565
|
+
res.json(response);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
app.use(
|
|
570
|
+
"/.well-known/agent-card.json",
|
|
571
|
+
agentCardHandler({ agentCardProvider: handler }),
|
|
572
|
+
);
|
|
573
|
+
app.use(
|
|
574
|
+
jsonRpcHandler({
|
|
575
|
+
requestHandler: handler,
|
|
576
|
+
userBuilder: UserBuilder.noAuthentication,
|
|
577
|
+
}),
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
// Bind every port: the primary (first) port is fatal on failure; the
|
|
581
|
+
// remaining contract ports are best-effort so a local run does not die
|
|
582
|
+
// when the other cloud's contract port happens to be taken.
|
|
583
|
+
const servers = ports.map((p, i) => {
|
|
584
|
+
const server = app.listen(p, host, () => {
|
|
585
|
+
console.log(
|
|
586
|
+
`[seller-agent] serving on ${host}:${p}${i === 0 ? "" : " (secondary contract port)"} (x402: ${seller.state})`,
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
if (i > 0) {
|
|
590
|
+
server.on("error", (e) => {
|
|
591
|
+
console.warn(
|
|
592
|
+
`[seller-agent] secondary contract port ${p} unavailable: ${(e as Error).message}`,
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return server;
|
|
597
|
+
});
|
|
598
|
+
process.once("SIGTERM", () => {
|
|
599
|
+
let open = servers.length;
|
|
600
|
+
for (const server of servers) {
|
|
601
|
+
server.close(() => {
|
|
602
|
+
open -= 1;
|
|
603
|
+
if (open === 0) process.exit(0);
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Run only as an entrypoint (`node unifiedMain.js` / the hosted runtime),
|
|
610
|
+
// never on import — tests import the builders above without starting a
|
|
611
|
+
// server.
|
|
612
|
+
const isMain =
|
|
613
|
+
process.argv[1] !== undefined &&
|
|
614
|
+
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
615
|
+
if (isMain) {
|
|
616
|
+
main().catch((e) => {
|
|
617
|
+
console.error("[seller-agent] fatal:", e);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[recipe]
|
|
2
2
|
name = "runtimes/agentcore"
|
|
3
|
-
description = "Runtime adapter for AWS Bedrock AgentCore. A project selects one or more public faces on one runtime and signer: A2A emits
|
|
3
|
+
description = "Runtime adapter for AWS Bedrock AgentCore. A project selects one or more public faces on one runtime and signer: A2A emits the cloud-portable unifiedMain.ts (also speaks Foundry invocations/responses), MCP emits mcpMain.ts, and A2A+MCP emits the A2A-native dualMain.ts plus mcpMain.ts as its MCP server library. X402 is an optional sibling route on whichever entrypoint is selected. The chain tools (tools.ts) + model factory (model.ts) are shared by every mode."
|
|
4
4
|
status = "a2a|mcp|both"
|
|
5
5
|
|
|
6
6
|
# Emits the AgentCore serving layer for the chosen protocol:
|
|
@@ -79,10 +79,10 @@ node = [
|
|
|
79
79
|
default = "a2a-codezip"
|
|
80
80
|
|
|
81
81
|
[modes.a2a-codezip]
|
|
82
|
-
files = ["{{PKG}}/
|
|
82
|
+
files = ["{{PKG}}/unifiedMain.ts", "{{PKG}}/executor.ts", "{{PKG}}/sellerCore.ts", "{{PKG}}/agentCard.ts"]
|
|
83
83
|
|
|
84
84
|
[modes.a2a-container]
|
|
85
|
-
files = ["{{PKG}}/
|
|
85
|
+
files = ["{{PKG}}/unifiedMain.ts", "{{PKG}}/executor.ts", "{{PKG}}/sellerCore.ts", "{{PKG}}/agentCard.ts", "{{PKG}}/Dockerfile", "{{PKG}}/.dockerignore"]
|
|
86
86
|
|
|
87
87
|
[modes.mcp-codezip]
|
|
88
88
|
files = ["{{PKG}}/mcpMain.ts"]
|