@foldrun/core 9.1.0 → 9.2.0

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 (44) hide show
  1. package/dist/src/a2a-client.d.ts +165 -0
  2. package/dist/src/a2a-client.js +440 -0
  3. package/dist/src/a2a-publish.d.ts +37 -0
  4. package/dist/src/a2a-publish.js +116 -0
  5. package/dist/src/a2a-remotes.d.ts +73 -0
  6. package/dist/src/a2a-remotes.js +192 -0
  7. package/dist/src/a2a-tools.d.ts +148 -0
  8. package/dist/src/a2a-tools.js +833 -0
  9. package/dist/src/api-tools.js +15 -10
  10. package/dist/src/browser-pod.js +4 -0
  11. package/dist/src/completions.js +8 -8
  12. package/dist/src/frontmatter-fields.js +2 -0
  13. package/dist/src/frontmatter-schema.js +4 -0
  14. package/dist/src/limits.js +22 -4
  15. package/dist/src/run-container.d.ts +10 -1
  16. package/dist/src/run-container.js +17 -0
  17. package/dist/src/runner.d.ts +10 -0
  18. package/dist/src/runner.js +158 -14
  19. package/dist/src/scan.js +102 -5
  20. package/dist/src/secrets.d.ts +30 -0
  21. package/dist/src/secrets.js +41 -1
  22. package/dist/src/starter.js +5 -1
  23. package/dist/src/store.d.ts +34 -0
  24. package/dist/src/store.js +28 -7
  25. package/dist/src/subagents.d.ts +16 -0
  26. package/dist/src/subagents.js +23 -2
  27. package/package.json +4 -1
  28. package/src/a2a-client.ts +517 -0
  29. package/src/a2a-publish.ts +120 -0
  30. package/src/a2a-remotes.ts +225 -0
  31. package/src/a2a-tools.ts +919 -0
  32. package/src/api-tools.ts +20 -8
  33. package/src/browser-pod.ts +3 -0
  34. package/src/completions.ts +8 -8
  35. package/src/frontmatter-fields.ts +2 -0
  36. package/src/frontmatter-schema.ts +4 -0
  37. package/src/limits.ts +22 -4
  38. package/src/run-container.ts +22 -0
  39. package/src/runner.ts +152 -11
  40. package/src/scan.ts +123 -6
  41. package/src/secrets.ts +43 -1
  42. package/src/starter.ts +5 -1
  43. package/src/store.ts +47 -2
  44. package/src/subagents.ts +29 -2
package/src/api-tools.ts CHANGED
@@ -20,7 +20,12 @@ import { createSdkMcpServer, tool, type SdkMcpToolDefinition } from "@anthropic-
20
20
  import type { ApiSpec } from "./store.ts";
21
21
  import { previewOrKeep, RESULT_KEEP_CHARS, type KeepIn } from "./tool-output.ts";
22
22
  import type { OperationSpec } from "./openapi.ts";
23
- import { resolveSecrets, materializeSecrets } from "./secrets.ts";
23
+ import { resolveSecrets, materializeCurrent } from "./secrets.ts";
24
+
25
+ /** Whose vault the secrets came from, on a host step: the account (and
26
+ * workspace) they are made live in. Null inside a sandbox, where the values
27
+ * crossed already resolved and there is no vault. */
28
+ type VaultScope = { tenant: string; workspace?: string } | null;
24
29
 
25
30
  // Collected whole up to this; the model sees a preview and where the rest is
26
31
  // kept (tool-output.ts), not a cut with the rest gone.
@@ -145,6 +150,7 @@ async function performRequest(
145
150
  req: Request,
146
151
  /** Where a long body is kept, when the agent can read it back (tool-output.ts). */
147
152
  keepIn?: KeepIn,
153
+ scope: VaultScope = null,
148
154
  ): Promise<ToolReply> {
149
155
  const started = Date.now();
150
156
  try {
@@ -153,8 +159,11 @@ async function performRequest(
153
159
  // Per call, not per build: an oauth2 secret's recipe becomes a
154
160
  // live access token here, so a step that runs for an hour keeps
155
161
  // getting fresh tokens (the exchange is cached until near expiry)
156
- // — and a recipe can never end up inside a header.
157
- const live = await materializeSecrets(env);
162
+ // — and a recipe can never end up inside a header. On a host, the
163
+ // vault's recipe as it is now, made live in the account's scope: a
164
+ // rotating refresh token the runner (or an earlier call) spent is never
165
+ // sent again (secrets.ts materializeCurrent).
166
+ const live = await materializeCurrent(env, scope);
158
167
  const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
159
168
  const url = new URL(api.base + path);
160
169
  // Base-URL confinement: a path can't escape the declared host.
@@ -243,6 +252,7 @@ function typedTool(
243
252
  bucket: TokenBucket | null,
244
253
  log: string[],
245
254
  keepIn?: KeepIn,
255
+ scope: VaultScope = null,
246
256
  ) {
247
257
  const keys = argKeys(op);
248
258
  const shape: Record<string, z.ZodTypeAny> = {};
@@ -292,11 +302,11 @@ function typedTool(
292
302
  query,
293
303
  headers,
294
304
  body: typeof values.body === "string" ? values.body : undefined,
295
- }, keepIn);
305
+ }, keepIn, scope);
296
306
  });
297
307
  }
298
308
 
299
- function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBucket | null, log: string[], keepIn?: KeepIn) {
309
+ function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBucket | null, log: string[], keepIn?: KeepIn, scope: VaultScope = null) {
300
310
  const typed = api.resolvedOperations?.length
301
311
  ? ` Prefer the typed ${api.name}_* tools; this one is the escape hatch for operations they do not cover.`
302
312
  : "";
@@ -321,7 +331,7 @@ function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBuc
321
331
  query: args.query ?? {},
322
332
  headers: {},
323
333
  body: args.body,
324
- }, keepIn),
334
+ }, keepIn, scope),
325
335
  );
326
336
  }
327
337
 
@@ -351,6 +361,8 @@ export function buildApiTools(
351
361
  missing: resolved.missing,
352
362
  }
353
363
  : resolveSecrets(tenant, neededSecrets, workspace);
364
+ // Read from the vault here: made live per call in its account's scope.
365
+ const scope: VaultScope = !resolved && tenant ? { tenant, ...(workspace ? { workspace } : {}) } : null;
354
366
  const log: string[] = [];
355
367
 
356
368
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the SDK types its own list this way
@@ -393,11 +405,11 @@ export function buildApiTools(
393
405
 
394
406
  const typedNames: string[] = [];
395
407
  for (const op of ops) {
396
- tools.push(typedTool(api, op, env, bucket, log, keepIn));
408
+ tools.push(typedTool(api, op, env, bucket, log, keepIn, scope));
397
409
  typedNames.push(typedToolName(api.name, op.id));
398
410
  methods[`mcp__foldrun_apis__${typedToolName(api.name, op.id)}`] = String(op.method ?? "").toUpperCase();
399
411
  }
400
- if (withGeneric) tools.push(genericTool(api, env, bucket, log, keepIn));
412
+ if (withGeneric) tools.push(genericTool(api, env, bucket, log, keepIn, scope));
401
413
 
402
414
  const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
403
415
  for (const n of names) {
@@ -162,6 +162,9 @@ export function classifyCall(tool: string, input: Record<string, unknown> | unde
162
162
  // tools: [peers] — reading the roster changes nothing; a message to a
163
163
  // colleague is said, and a re-run would say it twice.
164
164
  if (server === "foldrun_peers") return name === "peers_list" ? { write: false } : write("a message to a colleague");
165
+ // remotes: — reading where a request got to changes nothing; a send, a
166
+ // reply or a cancel acts on an outside agent's system.
167
+ if (server === "foldrun_a2a") return name === "a2a_status" ? { write: false } : write(`${name.replace(/^a2a_/, "")} to an outside agent`);
165
168
  if (server === "foldrun_scripts") {
166
169
  if (name !== "web") return write(`script ${name}`);
167
170
  const action = String(args.action ?? "").trim().toLowerCase();
@@ -394,9 +394,9 @@ export function completionsAt(
394
394
  // that field's files and nothing else. `[[search]]` in tools: is how a
395
395
  // tool named after a runtime group gets granted; offering the group
396
396
  // here would defeat the point of the brackets.
397
- const inlineOwner = line.match(/^\s*([a-zA-Z_]+):\s*(?:\[.*)?\[\[[^\]]*$/)?.[1];
397
+ const inlineOwner = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*):\s*(?:\[.*)?\[\[[^\]]*$/)?.[1];
398
398
  const blockOwner = /^\s+-\s*\[\[/.test(line)
399
- ? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_]+:\s*$/.test(l))?.trim().replace(":", "")
399
+ ? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "")
400
400
  : undefined;
401
401
  const owner = inlineOwner ?? blockOwner;
402
402
  const byField: Record<string, Completion[]> = {
@@ -460,7 +460,7 @@ export function completionsAt(
460
460
  // been chosen yet, one at a time. Fixed combinations were the wrong
461
461
  // model: nobody wants a menu of every permutation, they want to add the
462
462
  // next verb.
463
- const inlineList = line.match(/^(\s*)([a-zA-Z_]+):\s*\[([^\]]*)$/);
463
+ const inlineList = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*\[([^\]]*)$/);
464
464
  if (inlineList) {
465
465
  const [, , key, inside] = inlineList;
466
466
  // The inline and block forms of a list are the same field — `tools: [a, b]`
@@ -505,7 +505,7 @@ export function completionsAt(
505
505
  }
506
506
 
507
507
  // 4. `key: value` — the value side of a known field.
508
- const kv = line.match(/^(\s*)([a-zA-Z_]+):\s*(\S*)$/);
508
+ const kv = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*(\S*)$/);
509
509
  if (kv) {
510
510
  const [, , key, partial] = kv;
511
511
  const from = cursor - partial.length;
@@ -587,7 +587,7 @@ export function completionsAt(
587
587
  const partial = listItem[2];
588
588
  const from = cursor - partial.length;
589
589
  const priorLines = before.slice(0, lineStart).split("\n").reverse();
590
- const owner = priorLines.find((l) => /^\s*[a-zA-Z_]+:\s*$/.test(l))?.trim().replace(":", "");
590
+ const owner = priorLines.find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "");
591
591
  const byOwner: Record<string, Completion[]> = {
592
592
  // One list now: built-ins and your own tools are granted the same way.
593
593
  // Groups first (they cover most needs), then your own tools — the ones
@@ -629,7 +629,7 @@ export function completionsAt(
629
629
  const partial = mapping[2] ?? "";
630
630
  const priorLines = before.slice(0, lineStart).split("\n").reverse();
631
631
  const owner = priorLines
632
- .find((l) => /^\s*[a-zA-Z_]+:\s*$/.test(l))
632
+ .find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))
633
633
  ?.trim()
634
634
  .replace(":", "");
635
635
  const byOwner: Record<string, Completion[]> = {
@@ -666,7 +666,7 @@ export function completionsAt(
666
666
  // kind of file — the block takes nothing else.
667
667
  const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
668
668
  const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
669
- const nested = line.match(/^(\s+)([a-zA-Z_]*)$/);
669
+ const nested = line.match(/^(\s+)([a-zA-Z_][a-zA-Z0-9_]*|)$/);
670
670
  if (insideFrontmatter && nested) {
671
671
  const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
672
672
  const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
@@ -699,7 +699,7 @@ export function completionsAt(
699
699
  // this kind of file. This is where you learn the format by writing it.
700
700
  const fmEnd = text.indexOf("\n---", text.indexOf("---") + 3);
701
701
  const inFrontmatter = text.startsWith("---") && (fmEnd === -1 || cursor <= fmEnd);
702
- if (inFrontmatter && /^[a-zA-Z_][a-zA-Z_-]*$|^$/.test(line)) {
702
+ if (inFrontmatter && /^[a-zA-Z_][a-zA-Z0-9_-]*$|^$/.test(line)) {
703
703
  const from = cursor - line.length;
704
704
  return { from, query: line, items: filter(fieldsFor(path, kind), line), title: `${kind} fields` };
705
705
  }
@@ -88,6 +88,7 @@ const AGENT: FieldSpec[] = [
88
88
  { key: "scripts", type: "list", help: "programs in scripts/, each a tool" },
89
89
  { key: "apis", type: "list", help: "an HTTP API as a tool" },
90
90
  { key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
91
+ { key: "remotes", type: "list", help: "outside A2A agents it may call — [{name, card: the https URL of its agent card, secret?: a vault NAME sent as a bearer token, description?}]" },
91
92
  { key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
92
93
  { key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
93
94
  { key: "sandbox", type: "string", values: ["own", "parent"], help: "when delegated to as a sub-agent: own = its own sandbox, tools, secrets and network; parent (default) = inside the delegating step's" },
@@ -174,6 +175,7 @@ const FLOW: FieldSpec[] = [
174
175
  { key: "budget", type: ["number", "string"], help: "USD per run" },
175
176
  { key: "compute", type: "object", help: "{vcpu, memory, disk} one run's steps may hold together at once — only narrows the workspace's and account's" },
176
177
  { key: "live", type: "boolean", help: "true: steps may act for real in a test run" },
178
+ { key: "a2a", type: "object", help: "serve this flow to outside agents over A2A 1.0 — {publish: true, name?, description?, examples?, public_card?, input?}; changing it needs workspace:manage" },
177
179
  ];
178
180
 
179
181
  const TOOL: FieldSpec[] = [
@@ -10,6 +10,8 @@ import { cronProblem, parseCron, cronCanFire } from "./cron.ts";
10
10
  import { SECRET_NAME } from "./secrets.ts";
11
11
  import { modelSecretCollision } from "./model-credential.ts";
12
12
  import { resourcesProblem, computeProblem } from "./resources.ts";
13
+ import { remotesProblem } from "./a2a-remotes.ts";
14
+ import { a2aPublishProblem } from "./a2a-publish.ts";
13
15
 
14
16
  export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
15
17
 
@@ -33,6 +35,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
33
35
  "agent": {
34
36
  effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the step runs at the model's default`),
35
37
  apis: apisProblem,
38
+ remotes: remotesProblem,
36
39
  budget: (v) => budgetProblem(v, ["run"]),
37
40
  ask: (v) => askProblem(v),
38
41
  resources: resourcesProblem,
@@ -57,6 +60,7 @@ const CHECKS: Partial<Record<SchemaKind, Record<string, (value: unknown) => stri
57
60
  pause_when: (v) => (String(v).length <= 60 ? null : "is longer than 60 characters"),
58
61
  budget: (v) => budgetProblem(v, ["run"]),
59
62
  compute: computeProblem,
63
+ a2a: a2aPublishProblem,
60
64
  },
61
65
  "tool": {
62
66
  transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
package/src/limits.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  // web.search: 40 # one action of the web tool
6
6
  // web: 150 # every web action together
7
7
  // crm: 20 # an API, script or MCP tool, by the name it is granted as
8
+ // a2a: 10 # messages to outside agents (a2a_send and a2a_reply)
9
+ // a2a.status: 30 # polls of them, apart from the sends
8
10
  // calls: 300 # every tool call in the step, whatever the tool
9
11
  // repeat: 5 # the same call — one tool, the same input — at most this often
10
12
  //
@@ -22,6 +24,7 @@
22
24
  import crypto from "node:crypto";
23
25
  import { WEB_ACTIONS } from "./providers.ts";
24
26
  import { DELEGATE_TOOLS } from "./subagents.ts";
27
+ import { A2A_SERVER, A2A_LIMIT_KEY, A2A_LIMIT_ACTIONS } from "./a2a-remotes.ts";
25
28
 
26
29
  export type Limits = Record<string, number>;
27
30
 
@@ -103,8 +106,13 @@ export function readLimits(raw: unknown, where = "limits"): { limits: Limits; pr
103
106
  }
104
107
  if (k.includes(".")) {
105
108
  const [head, action] = k.split(".");
106
- if (head !== "web" || !(WEB_ACTIONS as readonly string[]).includes(action)) {
107
- problems.push(`${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}.`);
109
+ const web = head === "web" && (WEB_ACTIONS as readonly string[]).includes(action);
110
+ const a2a = head === A2A_LIMIT_KEY && (A2A_LIMIT_ACTIONS as readonly string[]).includes(action);
111
+ if (!web && !a2a) {
112
+ problems.push(
113
+ `${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}, ` +
114
+ `and ${A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`).join(", ")}.`,
115
+ );
108
116
  continue;
109
117
  }
110
118
  }
@@ -166,6 +174,15 @@ export function limitKeysFor(toolName: string, input: Record<string, unknown> |
166
174
  if (toolName === "WebSearch") { webAction("search"); return keys; }
167
175
  if (toolName === "WebFetch") { webAction("fetch"); return keys; }
168
176
  if ((DELEGATE_TOOLS as readonly string[]).includes(toolName)) return keys;
177
+ // remotes: — a message to an outside agent (a2a_send, a2a_reply) counts
178
+ // under `a2a`; every tool also under its own `a2a.<action>`, so polling
179
+ // (`a2a.status`) is capped apart from sending.
180
+ const a2a = new RegExp(`^mcp__${A2A_SERVER}__a2a_([a-z]+)$`).exec(toolName);
181
+ if (a2a) {
182
+ if (a2a[1] === "send" || a2a[1] === "reply") keys.push(A2A_LIMIT_KEY);
183
+ keys.push(`${A2A_LIMIT_KEY}.${a2a[1]}`);
184
+ return keys;
185
+ }
169
186
  const owner = owners[toolName];
170
187
  const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
171
188
  const name = owner
@@ -274,7 +291,7 @@ export function limitKeyProblems(
274
291
  ctx: { known: Iterable<string>; granted: Iterable<string> },
275
292
  ): { level: "error" | "warn"; message: string }[] {
276
293
  const out: { level: "error" | "warn"; message: string }[] = [];
277
- const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web"];
294
+ const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web", A2A_LIMIT_KEY];
278
295
  const known = new Set([...builtinKeys, ...ctx.known].map(normKey));
279
296
  const granted = new Set([...ctx.granted].flatMap((g) => {
280
297
  const n = normKey(g);
@@ -287,10 +304,11 @@ export function limitKeyProblems(
287
304
  }).map(normKey));
288
305
  for (const key of Object.keys(limits)) {
289
306
  if (key === TOTAL_KEY || key === REPEAT_KEY) continue;
290
- const base = key.startsWith("web.") ? "web" : key;
307
+ const base = key.startsWith("web.") ? "web" : key.startsWith(`${A2A_LIMIT_KEY}.`) ? A2A_LIMIT_KEY : key;
291
308
  const n = normKey(base);
292
309
  if (!known.has(n)) {
293
310
  const valid = [TOTAL_KEY, "web", ...WEB_ACTIONS.map((a) => `web.${a}`), ...[...new Set([...ctx.granted].map(String))].filter((g) => g !== "web")];
311
+ if ([...ctx.granted].map(String).includes(A2A_LIMIT_KEY)) valid.push(...A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`));
294
312
  out.push({ level: "error", message: `limits: ${key} — not a tool this agent could call. The keys: ${[...new Set(valid)].join(", ")}.` });
295
313
  } else if (!granted.has(n)) {
296
314
  out.push({ level: "warn", message: `limits: ${key} — this agent is not granted ${base}, so the limit never applies` });
@@ -44,6 +44,7 @@ import { isOperatorEvent, type OperatorEvent } from "./operator.ts";
44
44
  import type { SearchRoot, RunDigest } from "./context-tools.ts";
45
45
  import type { TranslatorSpec } from "./translator.ts";
46
46
  import { parseBrowserPod, type BrowserPodOutcome } from "./browser-pod.ts";
47
+ import type { RemoteSpec } from "./a2a-remotes.ts";
47
48
 
48
49
  /** What crosses the boundary. Everything in here is values — pre-resolved
49
50
  * secrets in headers, assembled prompt, serializable MCP configs. */
@@ -61,6 +62,10 @@ export interface ContainerStepInput {
61
62
  /** http/stdio configs only — in-process servers are rebuilt inside. */
62
63
  mcpServers: Record<string, McpServerConfig>;
63
64
  apis: ApiSpec[]; // headers pre-substituted from the vault, host-side
65
+ /** `remotes:` — outside A2A agents, each spec's `token` filled host-side
66
+ * (`${NAME}` for the egress proxy, or the value with no proxy), and the
67
+ * run and step its handles are kept under (a2a-tools.ts). */
68
+ a2a?: { remotes: RemoteSpec[]; runId: string | null; step: number | null };
64
69
  scripts: ScriptSpec[];
65
70
  /** The agent's `runtime:` declaration — the driver builds the venv/npm
66
71
  * prefix inside the container, where the network is. Without this,
@@ -749,6 +754,7 @@ try {
749
754
  process.env.FOLDRUN_DATA = "/home/agent/.foldrun";
750
755
  const { executeStep } = await import("@foldrun/core/step-exec");
751
756
  const { buildApiTools } = await import("@foldrun/core/api-tools");
757
+ const { buildA2ATools } = await import("@foldrun/core/a2a-tools");
752
758
  const { buildScriptTools } = await import("@foldrun/core/script-tools");
753
759
  const { buildConsultTools } = await import("@foldrun/core/agent-tools");
754
760
  const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
@@ -825,6 +831,20 @@ try {
825
831
  const toolEnv = { ...env };
826
832
  for (const [k, v] of Object.entries(env)) if (k.startsWith("FOLDRUN_DECLARED_")) toolEnv[k.slice("FOLDRUN_DECLARED_".length)] = v;
827
833
  const api = buildApiTools("", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);
834
+ // remotes: — the four a2a tools, rebuilt from values. Each token crossed in
835
+ // its spec (a placeholder the proxy fills for the card's origin alone), so
836
+ // nothing here asks a vault; handles go under the workspace's state/.
837
+ const a2a = input.a2a && Array.isArray(input.a2a.remotes) && input.a2a.remotes.length
838
+ ? buildA2ATools("", input.a2a.remotes, undefined, { env: {}, missing: [] }, {
839
+ stateDir: "/workspace/state/a2a/" + input.agentRel.split("/").pop(),
840
+ runId: input.a2a.runId,
841
+ step: input.a2a.step,
842
+ egress: env.FOLDRUN_EGRESS || null,
843
+ test: env.FOLDRUN_TEST_MODE === "1",
844
+ keepIn,
845
+ emit,
846
+ })
847
+ : { server: null, drainLog: () => [] };
828
848
  const script = buildScriptTools(
829
849
  agentDir,
830
850
  input.scripts,
@@ -901,6 +921,7 @@ try {
901
921
  mcpNames: input.mcpNames,
902
922
  mcpServers: {
903
923
  ...(api.server ? { foldrun_apis: api.server } : {}),
924
+ ...(a2a.server ? { foldrun_a2a: a2a.server } : {}),
904
925
  ...(script.server ? { foldrun_scripts: script.server } : {}),
905
926
  ...(consult.server ? { foldrun_agents: consult.server } : {}),
906
927
  ...(search.server ? { foldrun_search: search.server } : {}),
@@ -940,6 +961,7 @@ try {
940
961
  emit,
941
962
  });
942
963
  for (const line of api.drainLog()) emit("info", "api: " + line);
964
+ for (const line of a2a.drainLog()) emit("info", "a2a: " + line);
943
965
  for (const line of script.drainLog()) emit("info", "script: " + line);
944
966
  if (translator) {
945
967
  for (const line of translator.drainLog()) emit("info", line.startsWith("translator") ? line : "translator: " + line);