@bnbagent/studio-cli 0.0.10 → 0.0.11-alpha.2

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 +322 -125
  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,422 +0,0 @@
1
- /**
2
- * Azure AI Foundry Hosted Agents deploy host — the ERC-8183 seller over A2A.
3
- *
4
- * Generated by `bag init --runtime azure-foundry`. This is the
5
- * Foundry-hosted counterpart of the cloud-neutral A2A entrypoint
6
- * (`main.ts`). The deploy spec declares Foundry's minimal pass-through
7
- * `invocations` protocol for user-owned Azure and the `responses` protocol
8
- * for managed incoming A2A. This host implements both documented container
9
- * endpoints on :8088 plus GET /readiness.
10
- *
11
- * ## How A2A reaches the seller skills on Foundry
12
- *
13
- * The seller's two skills live behind the provider's pass-through invoke
14
- * path. {@link SkillRouter} parses the inbound user text as
15
- * a JSON skill envelope (`{"skill": "negotiate", ...}` /
16
- * `{"skill": "notify_funded", ...}`) and dispatches it through the SHARED
17
- * `SellerAgentExecutor.dispatch` — the same fixed-code skills (`signing.ts`)
18
- * the cloud-neutral @a2a-js path (`main.ts`) runs. The result dict is
19
- * serialised back to text (Foundry's incoming A2A is text-modality only).
20
- *
21
- * Money is NEVER decided by the LLM: `negotiate` is rule-based + EIP-191
22
- * signed in `signing.ts`; the LLM runs ONLY inside the `notify_funded`
23
- * background work hook (produces the deliverable text). The seller has no
24
- * free-form chat skill, so a non-envelope message gets a deterministic
25
- * "unknown skill" reply — no LLM on the request path.
26
- *
27
- * LLM wiring: the work hook talks to the project's OWN provider over an
28
- * OpenAI-compatible client built from env vars `bag deploy --provider azure`
29
- * injects (`BNBAGENT_LLM_BASE_URL` / `BNBAGENT_LLM_MODEL` plus
30
- * `BNBAGENT_LLM_API_KEY`). Wallet material and provider/storage secrets are
31
- * stored by bnbagent-deploy in a Foundry CustomKeys project connection and
32
- * arrive as environment variables before the process starts.
33
- */
34
-
35
- import { randomUUID } from "node:crypto";
36
- import { pathToFileURL } from "node:url";
37
- import { createOpenAI } from "@ai-sdk/openai";
38
- import { loadStudioToml } from "@bnbagent/studio-runtime/config";
39
- import {
40
- ensureAltanaSessionLoaded,
41
- ensureKeystoreMaterialized,
42
- ensureTwakMaterialized,
43
- } from "@bnbagent/studio-runtime/wallet";
44
- import { generateText } from "ai";
45
- import express from "express";
46
- import { type RunWork, SellerAgentExecutor } from "./executor.js";
47
-
48
- const APP_NAME = "agent";
49
-
50
- // LLM wiring `bag deploy --provider azure` injects so the work hook talks to the
51
- // project's OWN provider (studio.toml [llm] — pieverse-llm / openrouter /
52
- // openai), all OpenAI-compatible. base URL + model ride the runtime env the
53
- // deploy injects; the API key comes from the provider-managed CustomKeys
54
- // connection and is present in the environment before this process starts.
55
- const LLM_BASE_URL_ENV = "BNBAGENT_LLM_BASE_URL";
56
- const LLM_MODEL_ENV = "BNBAGENT_LLM_MODEL";
57
- const LLM_API_KEY_ENV = "BNBAGENT_LLM_API_KEY";
58
-
59
- const log = {
60
- info: (msg: string) => console.log(`[seller-agent.foundry] ${msg}`),
61
- warn: (msg: string) => console.warn(`[seller-agent.foundry] WARNING ${msg}`),
62
- };
63
-
64
- /**
65
- * Drop a blank APPLICATIONINSIGHTS_CONNECTION_STRING (design §9.3).
66
- *
67
- * The Azure Monitor exporter parses this env var eagerly; an EMPTY string
68
- * (vs an absent var) crashes its parser. The Hosted Agents runtime can
69
- * inject it empty when monitoring is not configured, so remove it when
70
- * blank before any telemetry init.
71
- */
72
- function scrubBlankAppInsights(): void {
73
- const val = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING;
74
- if (val !== undefined && val.trim() === "") {
75
- delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING;
76
- }
77
- }
78
-
79
- /**
80
- * Build the work hook's OpenAI-compatible model from the injected LLM
81
- * config.
82
- *
83
- * Reads three fixed env vars `bag deploy --provider azure` populates from studio.toml
84
- * `[llm]`: base URL + model (runtime env, plaintext) and the API key (the
85
- * CustomKeys connection). Used ONLY by the notify_funded background work
86
- * hook to produce the deliverable text.
87
- */
88
- function buildChatModel() {
89
- const baseUrl = process.env[LLM_BASE_URL_ENV];
90
- const model = process.env[LLM_MODEL_ENV];
91
- const apiKey = process.env[LLM_API_KEY_ENV];
92
- const missing = (
93
- [
94
- [LLM_BASE_URL_ENV, baseUrl],
95
- [LLM_MODEL_ENV, model],
96
- [LLM_API_KEY_ENV, apiKey],
97
- ] as const
98
- )
99
- .filter(([, value]) => !value)
100
- .map(([name]) => name);
101
- if (missing.length > 0) {
102
- throw new Error(
103
- `LLM is not configured: missing ${missing.join(", ")}. ` +
104
- "`bag deploy --provider azure` seeds base URL + model from studio.toml [llm] and " +
105
- "the API key from the Foundry CustomKeys connection — re-deploy " +
106
- "(or export these vars for a local run).",
107
- );
108
- }
109
- return createOpenAI({ baseURL: baseUrl, apiKey }).chat(model as string);
110
- }
111
-
112
- /** Deliverable `generator` label: this seller's own name from studio.toml. */
113
- function generatorTag(): string {
114
- let name = "";
115
- try {
116
- const cfg = loadStudioToml();
117
- name = String(((cfg.project ?? {}) as Record<string, unknown>).name ?? "");
118
- } catch {
119
- // a metadata label must never break delivery
120
- return APP_NAME;
121
- }
122
- return name.endsWith("-agent")
123
- ? name.slice(0, -"-agent".length)
124
- : name || APP_NAME;
125
- }
126
-
127
- /** studio.toml `[network].default` (best-effort; used by the funded sweep). */
128
- function defaultNetwork(): string {
129
- try {
130
- const cfg = loadStudioToml();
131
- return String(
132
- ((cfg.network ?? {}) as Record<string, unknown>).default ?? "bsc-testnet",
133
- );
134
- } catch {
135
- return "bsc-testnet";
136
- }
137
- }
138
-
139
- /**
140
- * Build the SHARED seller executor with an AI-SDK-backed work hook.
141
- *
142
- * `negotiate` is rule-based (no LLM); the work hook is called ONLY by the
143
- * background `notify_funded` delivery to produce the deliverable text. We
144
- * back it with the project's own OpenAI-compatible provider (the same
145
- * wiring the Foundry deploy already injects), so no extra LLM secrets
146
- * beyond the `BNBAGENT_LLM_*` the deploy injects are needed.
147
- */
148
- export function buildExecutor(): SellerAgentExecutor {
149
- // `negotiate` is fixed code and does not need an LLM. Resolve the model
150
- // lazily only when funded work actually runs so the Foundry process can
151
- // become ready (and negotiation can work) even when an operator deliberately
152
- // bypasses the deploy-time LLM readiness gate for diagnosis.
153
- let model: ReturnType<typeof buildChatModel> | undefined;
154
- const runWork: RunWork = async (prompt, { abortSignal }) => {
155
- model ??= buildChatModel();
156
- // One-shot: each funded job is independent, so no session is threaded.
157
- const result = await generateText({
158
- model,
159
- system:
160
- "You are a seller agent. You do the actual work once a job is funded. " +
161
- "Be concrete, complete, and self-contained. If a paid-data tool such " +
162
- "as `buy_with_x402` is available to you, USE IT to fetch the data a " +
163
- "task needs — those merchants (e.g. CoinMarketCap) charge via on-chain " +
164
- "wallet payment, NOT an API key; never reply that you cannot complete " +
165
- "the task for lack of an API key.",
166
- prompt,
167
- abortSignal,
168
- });
169
- return result.text.trim();
170
- };
171
- return new SellerAgentExecutor({
172
- runWork,
173
- generator: generatorTag(),
174
- network: defaultNetwork(),
175
- });
176
- }
177
-
178
- /**
179
- * The skill-router shim routing Foundry invocations to seller skills.
180
- *
181
- * The pass-through host receives a JSON body whose `input` is the serialized
182
- * skill envelope. We parse that text and
183
- * dispatch it DETERMINISTICALLY through `SellerAgentExecutor.dispatch`
184
- * (signing.ts fixed code; LLM only inside the notify_funded background
185
- * work). The result is serialised back to text. A non-envelope message
186
- * returns the same "unknown skill" reply the @a2a-js path gives — there is
187
- * no free-form chat skill.
188
- */
189
- export class SkillRouter {
190
- readonly name: string;
191
- readonly description =
192
- "ERC-8183 seller agent (negotiate + notify_funded) over A2A.";
193
- private readonly executor: SellerAgentExecutor;
194
-
195
- constructor(executor: SellerAgentExecutor, opts: { name?: string } = {}) {
196
- this.executor = executor;
197
- this.name = opts.name ?? "seller_agent";
198
- }
199
-
200
- /** Route one inbound text turn to the seller skills; returns JSON text. */
201
- async run(text: string | null | undefined): Promise<string> {
202
- const envelope = extractEnvelope(text);
203
- const result =
204
- envelope === null
205
- ? {
206
- error:
207
- 'expected a JSON skill envelope, e.g. {"skill": "negotiate", ...}',
208
- skills: ["negotiate", "notify_funded"],
209
- }
210
- : await this.executor.dispatch(envelope);
211
- return JSON.stringify(result);
212
- }
213
- }
214
-
215
- /**
216
- * Parse inbound user text into a `{"skill": ...}` dict, or null.
217
- *
218
- * Foundry's incoming A2A is text-modality only, so the buyer sends the skill
219
- * envelope as a JSON string. Non-JSON / non-object text → null (the caller
220
- * replies with a deterministic "unknown skill").
221
- */
222
- export function extractEnvelope(
223
- text: string | null | undefined,
224
- ): Record<string, unknown> | null {
225
- if (!text) {
226
- return null;
227
- }
228
- let obj: unknown;
229
- try {
230
- obj = JSON.parse(text);
231
- } catch {
232
- return null;
233
- }
234
- return obj !== null && typeof obj === "object" && !Array.isArray(obj)
235
- ? (obj as Record<string, unknown>)
236
- : null;
237
- }
238
-
239
- /** Extract the latest text turn from an OpenAI Responses request. Foundry's
240
- * incoming A2A adapter is text-only and projects the caller message here. */
241
- export function responsesInputText(input: unknown): string | null {
242
- if (typeof input === "string") {
243
- return input;
244
- }
245
- if (!Array.isArray(input)) {
246
- return null;
247
- }
248
- const texts: string[] = [];
249
- for (const item of input) {
250
- if (typeof item === "string") {
251
- texts.push(item);
252
- continue;
253
- }
254
- if (!item || typeof item !== "object") {
255
- continue;
256
- }
257
- const content = (item as Record<string, unknown>).content;
258
- if (typeof content === "string") {
259
- texts.push(content);
260
- continue;
261
- }
262
- if (!Array.isArray(content)) {
263
- continue;
264
- }
265
- for (const part of content) {
266
- if (part && typeof part === "object") {
267
- const text = (part as Record<string, unknown>).text;
268
- if (typeof text === "string") texts.push(text);
269
- }
270
- }
271
- }
272
- return texts.at(-1) ?? null;
273
- }
274
-
275
- function responseId(prefix: string): string {
276
- return `${prefix}_${randomUUID().replaceAll("-", "")}`;
277
- }
278
-
279
- function responseEnvelope(text: string, model: string) {
280
- const responseIdValue = responseId("resp");
281
- const messageId = responseId("msg");
282
- const part = { type: "output_text", annotations: [], logprobs: [], text };
283
- const item = {
284
- id: messageId,
285
- type: "message",
286
- status: "completed",
287
- role: "assistant",
288
- content: [part],
289
- };
290
- return {
291
- id: responseIdValue,
292
- object: "response",
293
- created_at: Math.floor(Date.now() / 1000),
294
- status: "completed",
295
- error: null,
296
- incomplete_details: null,
297
- instructions: null,
298
- max_output_tokens: null,
299
- model,
300
- output: [item],
301
- output_text: text,
302
- parallel_tool_calls: true,
303
- previous_response_id: null,
304
- reasoning: { effort: null, summary: null },
305
- store: true,
306
- temperature: 1,
307
- text: { format: { type: "text" } },
308
- tool_choice: "auto",
309
- tools: [],
310
- top_p: 1,
311
- truncation: "disabled",
312
- usage: {
313
- input_tokens: 0,
314
- input_tokens_details: { cached_tokens: 0 },
315
- output_tokens: 0,
316
- output_tokens_details: { reasoning_tokens: 0 },
317
- total_tokens: 0,
318
- },
319
- metadata: {},
320
- };
321
- }
322
-
323
- function sendStreamingResponse(res: express.Response, response: ReturnType<typeof responseEnvelope>): void {
324
- const item = response.output[0];
325
- const part = item.content[0];
326
- const events = [
327
- { type: "response.created", sequence_number: 0, response: { ...response, status: "in_progress", output: [] } },
328
- { type: "response.output_item.added", sequence_number: 1, output_index: 0, item: { ...item, status: "in_progress", content: [] } },
329
- { type: "response.content_part.added", sequence_number: 2, output_index: 0, item_id: item.id, content_index: 0, part: { ...part, text: "" } },
330
- { type: "response.output_text.delta", sequence_number: 3, output_index: 0, item_id: item.id, content_index: 0, delta: part.text },
331
- { type: "response.output_text.done", sequence_number: 4, output_index: 0, item_id: item.id, content_index: 0, text: part.text },
332
- { type: "response.content_part.done", sequence_number: 5, output_index: 0, item_id: item.id, content_index: 0, part },
333
- { type: "response.output_item.done", sequence_number: 6, output_index: 0, item },
334
- { type: "response.completed", sequence_number: 7, response },
335
- ];
336
- res.status(200);
337
- res.setHeader("content-type", "text/event-stream");
338
- res.setHeader("cache-control", "no-cache");
339
- for (const event of events) {
340
- res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
341
- }
342
- res.end("data: [DONE]\n\n");
343
- }
344
-
345
- async function main(): Promise<void> {
346
- scrubBlankAppInsights();
347
-
348
- // Wallet material arrives through the provider-managed CustomKeys env,
349
- // never the image; materialize it on disk once before any signing (no-op
350
- // for the other wallet kind and locally, where it already lives on disk).
351
- ensureKeystoreMaterialized();
352
- ensureTwakMaterialized();
353
- await ensureAltanaSessionLoaded();
354
-
355
- // Cold-start diagnostic (presence booleans ONLY — never secret values):
356
- // makes a missing CustomKeys injection obvious in the invoke logs instead
357
- // of failing opaquely deep inside the twak CLI ("No wallet password
358
- // found").
359
- log.info(
360
- "runtime-secrets cold-start: " +
361
- `TWAK_WALLET_PASSWORD=${Boolean(process.env.TWAK_WALLET_PASSWORD)} ` +
362
- `TWAK_ACCESS_ID=${Boolean(process.env.TWAK_ACCESS_ID)} ` +
363
- `TWAK_CREDENTIALS_JSON=${Boolean(process.env.TWAK_CREDENTIALS_JSON)} ` +
364
- `LLM_API_KEY=${Boolean(process.env[LLM_API_KEY_ENV])}`,
365
- );
366
-
367
- const router = new SkillRouter(buildExecutor(), { name: generatorTag() });
368
-
369
- // Foundry Invocations is a pass-through JSON contract. bnbagent-deploy's
370
- // positional invoke helper normalizes text to {"input":"..."}; advanced
371
- // callers may send the same shape directly.
372
- const app = express();
373
- app.use(express.json());
374
- app.post("/invocations", async (req, res) => {
375
- const input = (req.body ?? {}) as Record<string, unknown>;
376
- const text = typeof input.input === "string" ? input.input : null;
377
- res.json({ output: await router.run(text) });
378
- });
379
- // The managed platform selects Foundry's Responses protocol so the agent
380
- // can be exposed through incoming A2A. Keep this adapter small and
381
- // deterministic: Foundry converts A2A text to Responses input, and the
382
- // seller result becomes one assistant output_text item.
383
- app.post("/responses", async (req, res) => {
384
- const input = (req.body ?? {}) as Record<string, unknown>;
385
- const output = await router.run(responsesInputText(input.input));
386
- const response = responseEnvelope(output, typeof input.model === "string" ? input.model : APP_NAME);
387
- if (input.stream === true) {
388
- sendStreamingResponse(res, response);
389
- } else {
390
- res.json(response);
391
- }
392
- });
393
- // Required Foundry Hosted Agent health contract. The platform will not
394
- // create a session or forward invocations until this returns HTTP 200.
395
- app.get("/readiness", (_req, res) => {
396
- res.json({ status: "READY" });
397
- });
398
- app.get("/ping", (_req, res) => {
399
- res.json({ status: "HEALTHY" });
400
- });
401
-
402
- // Foundry's documented default is 8088; PORT remains the platform override.
403
- const port = Number(process.env.PORT || process.env.AGENT_PORT || "8088");
404
- const server = app.listen(port, "0.0.0.0", () => {
405
- log.info(`Invocations + Responses host serving on 0.0.0.0:${port}`);
406
- });
407
- process.once("SIGTERM", () => {
408
- server.close(() => process.exit(0));
409
- });
410
- }
411
-
412
- // Run only as an entrypoint, never on import (tests import SkillRouter /
413
- // extractEnvelope / buildExecutor without starting a server).
414
- const isMain =
415
- process.argv[1] !== undefined &&
416
- import.meta.url === pathToFileURL(process.argv[1]).href;
417
- if (isMain) {
418
- main().catch((e) => {
419
- console.error("[seller-agent.foundry] fatal:", e);
420
- process.exit(1);
421
- });
422
- }
@@ -1,196 +0,0 @@
1
- /**
2
- * Single A2A seller agent entrypoint — cloud-neutral (azure-foundry runtime).
3
- *
4
- * The x402 seller is intentionally dormant on Azure Foundry in v1. Use the
5
- * platform deployment target for the anonymous HTTP-envelope tunnel.
6
- *
7
- * This is the `--protocol A2A` peer of `mcpMain.ts` for the azure-foundry
8
- * runtime. It serves the SAME two ERC-8183 seller skills (`negotiate` +
9
- * `notify_funded`) over the A2A protocol as the AgentCore entrypoint, using
10
- * the official `@a2a-js/sdk` express integration directly, so this agent
11
- * carries no cloud-vendor serving dependency (commitments #4/#5 — jump-ship,
12
- * no lock-in). The executor + agent card are shared, unmodified, with the
13
- * Foundry deploy host (`executor.ts` / `agentCard.ts`).
14
- *
15
- * Scope: the azure-foundry DEPLOY
16
- * host speaks the Invocations protocol (`foundryMain.ts`, what Foundry's
17
- * invoke path calls). THIS A2A entrypoint is for LOCAL run / dev (`bag dev`) and
18
- * the eventual cross-cloud A2A transport — `bag dev` runs `node main.js`
19
- * directly, the same way it runs the AgentCore CodeZip. There is no
20
- * AgentCore scale-to-zero here, so no HEALTHY_BUSY `/ping` is needed: the
21
- * node process stays up, so the background `notify_funded` delivery
22
- * completes on its own. A STATIC 200 `/ping` is still served purely so
23
- * health-check clients that probe `/ping` behave uniformly with the
24
- * AgentCore runtime — it is a compatibility shim, never a busy signal
25
- * (liveness in A2A is the card).
26
- *
27
- * A2A skills (executor.ts):
28
- *
29
- * negotiate → read the FIXED list price → CLAMP to [min,max] → EIP-191 SIGN
30
- * the offer (no LLM, no tools) → return the signed offer (or reject)
31
- * notify_funded → re-verify the funded job on-chain (fast) → ACK accepted at once,
32
- * then in the BACKGROUND: LLM work → manifest → storage →
33
- * submitResult (SIGN + broadcast). The buyer polls the chain for
34
- * the deliverable.
35
- *
36
- * ## Boundaries (do NOT cross — they are the whole point)
37
- *
38
- * - The agent does ALL deterministic SIGNING. ALL signing is FIXED code in
39
- * `signing.ts` — NEVER an LLM-callable tool (money never in the LLM).
40
- * - The price is a FIXED list price from studio.toml (clamped before
41
- * signing) — the LLM never prices; it only PRODUCES the work text in
42
- * `notify_funded`.
43
- * - Chain access for the LLM is READ-ONLY tools only (`tools.ts`).
44
- * - `settle` (claim payment after the dispute window) is operator-driven —
45
- * run `bag erc8183 settle <job_id>`; it is deliberately NOT an A2A skill.
46
- */
47
-
48
- import { pathToFileURL } from "node:url";
49
- import { DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
50
- import {
51
- agentCardHandler,
52
- jsonRpcHandler,
53
- UserBuilder,
54
- } from "@a2a-js/sdk/server/express";
55
- import { loadStudioToml } from "@bnbagent/studio-runtime/config";
56
- import {
57
- ensureAltanaSessionLoaded,
58
- ensureKeystoreMaterialized,
59
- ensureTwakMaterialized,
60
- } from "@bnbagent/studio-runtime/wallet";
61
- import { generateText, stepCountIs } from "ai";
62
- import express from "express";
63
- import { buildAgentCard } from "./agentCard.js";
64
- import { type RunWork, SellerAgentExecutor } from "./executor.js";
65
- import { buildModel } from "./model.js";
66
- import { LLM_READ_TOOLS } from "./tools.js";
67
-
68
- const APP_NAME = "agent";
69
-
70
- /**
71
- * Deliverable `generator` label: this seller's own name, read from
72
- * studio.toml `[project].name` (minus the `-agent` suffix). Best-effort —
73
- * falls back to `APP_NAME` if the config can't be read.
74
- */
75
- function generatorTag(): string {
76
- let name = "";
77
- try {
78
- const cfg = loadStudioToml();
79
- name = String(((cfg.project ?? {}) as Record<string, unknown>).name ?? "");
80
- } catch {
81
- // a metadata label must never break delivery
82
- return APP_NAME;
83
- }
84
- return name.endsWith("-agent")
85
- ? name.slice(0, -"-agent".length)
86
- : name || APP_NAME;
87
- }
88
-
89
- /** studio.toml `[network].default` (best-effort; used by the funded sweep). */
90
- function defaultNetwork(): string {
91
- try {
92
- const cfg = loadStudioToml();
93
- return String(
94
- ((cfg.network ?? {}) as Record<string, unknown>).default ?? "bsc-testnet",
95
- );
96
- } catch {
97
- return "bsc-testnet";
98
- }
99
- }
100
-
101
- // ── One-shot LLM helper (the executor's work hook) ────────────────────────────
102
- // The LLM runs ONLY in the background `notify_funded` work (the value hook).
103
- // `negotiate` is rule-based and never touches the LLM. The read-only chain
104
- // tools are attached so the work can read on-chain context. Signing / x402 /
105
- // settle are NEVER tools — they are fixed code in signing.ts.
106
- export function buildRunWork(): RunWork {
107
- // The model is resolved LAZILY on first delivery, not at boot: a seller
108
- // with no provider key yet must still serve negotiate (which never calls
109
- // the LLM) — missing-key errors surface at notify_funded delivery time.
110
- let model: ReturnType<typeof buildModel> | undefined;
111
- return async (prompt, { abortSignal }) => {
112
- model ??= buildModel(); // managed model with the auto-renew hook (work only)
113
- const result = await generateText({
114
- model,
115
- system:
116
- "You are a seller agent. You do the actual work once a job is funded. " +
117
- "Be concrete and concise. Use the read-only chain tools when on-chain " +
118
- "context helps. If a paid-data tool such as `buy_with_x402` is available " +
119
- "to you, USE IT to fetch the data a task needs — those merchants (e.g. " +
120
- "CoinMarketCap) charge via on-chain wallet payment, NOT an API key; never " +
121
- "reply that you cannot complete the task for lack of an API key.",
122
- prompt,
123
- tools: LLM_READ_TOOLS, // READ-ONLY; signing is never an LLM tool
124
- stopWhen: stepCountIs(8),
125
- abortSignal,
126
- });
127
- return result.text.trim();
128
- };
129
- }
130
-
131
- // ── serving ───────────────────────────────────────────────────────────────────
132
-
133
- async function main(): Promise<void> {
134
- // Wallet material is NEVER bundled into the deploy artifact. `bag deploy`
135
- // injects it through a Foundry CustomKeys connection before process start;
136
- // these calls materialize it on disk before signing. Each is a no-op for
137
- // the other wallet kind and locally, where it already lives on disk.
138
- ensureKeystoreMaterialized();
139
- ensureTwakMaterialized();
140
- await ensureAltanaSessionLoaded();
141
-
142
- // The executor backs the seller skills with signing.ts fixed code (NEVER
143
- // an LLM tool). The @a2a-js/sdk express app exposes the agent card at
144
- // /.well-known/agent-card.json and JSON-RPC message/send at /.
145
- const executor = new SellerAgentExecutor({
146
- runWork: buildRunWork(),
147
- generator: generatorTag(),
148
- network: defaultNetwork(),
149
- });
150
- const handler = new DefaultRequestHandler(
151
- buildAgentCard(),
152
- new InMemoryTaskStore(),
153
- executor,
154
- );
155
-
156
- const app = express();
157
-
158
- // Static liveness probe. NOT an AgentCore scale-to-zero signal (there is
159
- // no AgentCore here) and NOT part of the A2A protocol (the agent card is
160
- // the A2A-native liveness). Served as a plain 200 so health-check clients
161
- // that probe /ping behave uniformly with the AgentCore runtime.
162
- app.get("/ping", (_req, res) => {
163
- res.json({ status: "HEALTHY" });
164
- });
165
-
166
- app.use(
167
- "/.well-known/agent-card.json",
168
- agentCardHandler({ agentCardProvider: handler }),
169
- );
170
- app.use(
171
- jsonRpcHandler({
172
- requestHandler: handler,
173
- userBuilder: UserBuilder.noAuthentication,
174
- }),
175
- );
176
-
177
- // AGENT_PORT is the local-dev port `bag dev` sets (9000 default); PORT
178
- // wins if a host platform injects it.
179
- const host = process.env.AGENT_HOST || "0.0.0.0";
180
- const port = Number(process.env.PORT || process.env.AGENT_PORT || "9000");
181
- app.listen(port, host, () => {
182
- console.log(`[seller-agent] A2A serving on ${host}:${port}`);
183
- });
184
- }
185
-
186
- // Run only as an entrypoint (`node main.js` / `bag dev`), never on import —
187
- // tests import the builders above without starting a server.
188
- const isMain =
189
- process.argv[1] !== undefined &&
190
- import.meta.url === pathToFileURL(process.argv[1]).href;
191
- if (isMain) {
192
- main().catch((e) => {
193
- console.error("[seller-agent] fatal:", e);
194
- process.exit(1);
195
- });
196
- }