@foldrun/core 2.4.0 → 2.6.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 (149) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +2 -2
  4. package/dist/src/agent-tools.js +2 -2
  5. package/dist/src/agents-md.d.ts +5 -1
  6. package/dist/src/agents-md.js +42 -18
  7. package/dist/src/alerts.d.ts +60 -0
  8. package/dist/src/alerts.js +161 -0
  9. package/dist/src/browser-pod.js +4 -0
  10. package/dist/src/capture.d.ts +23 -0
  11. package/dist/src/capture.js +80 -0
  12. package/dist/src/compare.d.ts +57 -0
  13. package/dist/src/compare.js +116 -0
  14. package/dist/src/completions.js +26 -3
  15. package/dist/src/deploy.d.ts +36 -1
  16. package/dist/src/deploy.js +237 -13
  17. package/dist/src/egress.d.ts +16 -1
  18. package/dist/src/evals.d.ts +27 -2
  19. package/dist/src/evals.js +62 -19
  20. package/dist/src/flow-lint.d.ts +14 -0
  21. package/dist/src/flow-lint.js +35 -0
  22. package/dist/src/flow-patterns.js +29 -14
  23. package/dist/src/frontmatter-fields.d.ts +27 -0
  24. package/dist/src/frontmatter-fields.js +60 -3
  25. package/dist/src/frontmatter-guard.d.ts +19 -0
  26. package/dist/src/frontmatter-guard.js +67 -0
  27. package/dist/src/frontmatter-schema.d.ts +2 -2
  28. package/dist/src/frontmatter-schema.js +33 -2
  29. package/dist/src/frontmatter-text.d.ts +31 -0
  30. package/dist/src/frontmatter-text.js +102 -0
  31. package/dist/src/history.d.ts +4 -0
  32. package/dist/src/history.js +15 -0
  33. package/dist/src/hooks.js +1 -1
  34. package/dist/src/insights.d.ts +30 -0
  35. package/dist/src/insights.js +152 -0
  36. package/dist/src/issues.d.ts +140 -0
  37. package/dist/src/issues.js +302 -0
  38. package/dist/src/judge-verdict.d.ts +19 -0
  39. package/dist/src/judge-verdict.js +54 -0
  40. package/dist/src/library.js +1 -1
  41. package/dist/src/limits.d.ts +8 -0
  42. package/dist/src/limits.js +36 -5
  43. package/dist/src/linkable.js +1 -1
  44. package/dist/src/matter.d.ts +42 -0
  45. package/dist/src/matter.js +132 -0
  46. package/dist/src/model-credential.d.ts +14 -0
  47. package/dist/src/model-credential.js +40 -0
  48. package/dist/src/network.d.ts +12 -1
  49. package/dist/src/network.js +35 -8
  50. package/dist/src/notify.d.ts +16 -0
  51. package/dist/src/notify.js +17 -7
  52. package/dist/src/okf.js +1 -1
  53. package/dist/src/online-checks.d.ts +76 -0
  54. package/dist/src/online-checks.js +432 -0
  55. package/dist/src/operator.d.ts +277 -0
  56. package/dist/src/operator.js +176 -2
  57. package/dist/src/otel-config.d.ts +18 -0
  58. package/dist/src/otel-config.js +69 -0
  59. package/dist/src/otel.d.ts +38 -0
  60. package/dist/src/otel.js +85 -0
  61. package/dist/src/outcome.d.ts +40 -0
  62. package/dist/src/outcome.js +77 -0
  63. package/dist/src/package.js +9 -2
  64. package/dist/src/platform.d.ts +1 -1
  65. package/dist/src/prompt-layers.d.ts +30 -0
  66. package/dist/src/prompt-layers.js +77 -0
  67. package/dist/src/provider-health.js +2 -2
  68. package/dist/src/providers.d.ts +5 -0
  69. package/dist/src/providers.js +2 -2
  70. package/dist/src/run-container.d.ts +25 -2
  71. package/dist/src/run-container.js +61 -6
  72. package/dist/src/runner.d.ts +53 -2
  73. package/dist/src/runner.js +471 -102
  74. package/dist/src/runs-filter.d.ts +48 -0
  75. package/dist/src/runs-filter.js +73 -0
  76. package/dist/src/scan.d.ts +30 -1
  77. package/dist/src/scan.js +145 -14
  78. package/dist/src/starter.js +27 -2
  79. package/dist/src/step-exec.d.ts +39 -3
  80. package/dist/src/step-exec.js +118 -15
  81. package/dist/src/store.d.ts +144 -3
  82. package/dist/src/store.js +150 -7
  83. package/dist/src/tokens.d.ts +38 -0
  84. package/dist/src/tokens.js +57 -0
  85. package/dist/src/tool-names.js +1 -1
  86. package/dist/src/tool-programs.js +1 -1
  87. package/dist/src/trace-tree.d.ts +40 -0
  88. package/dist/src/trace-tree.js +156 -0
  89. package/dist/src/translator.d.ts +8 -0
  90. package/dist/src/translator.js +7 -2
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/workspace-check.d.ts +18 -0
  93. package/dist/src/workspace-check.js +34 -0
  94. package/package.json +2 -2
  95. package/src/agent-activity.ts +32 -0
  96. package/src/agent-import.ts +2 -2
  97. package/src/agent-tools.ts +2 -2
  98. package/src/agents-md.ts +39 -14
  99. package/src/alerts.ts +190 -0
  100. package/src/browser-pod.ts +3 -0
  101. package/src/capture.ts +88 -0
  102. package/src/compare.ts +146 -0
  103. package/src/completions.ts +27 -3
  104. package/src/deploy.ts +238 -12
  105. package/src/egress.ts +12 -2
  106. package/src/evals.ts +64 -19
  107. package/src/flow-lint.ts +36 -0
  108. package/src/flow-patterns.ts +26 -13
  109. package/src/frontmatter-fields.ts +83 -3
  110. package/src/frontmatter-guard.ts +70 -0
  111. package/src/frontmatter-schema.ts +33 -4
  112. package/src/frontmatter-text.ts +109 -0
  113. package/src/history.ts +13 -0
  114. package/src/hooks.ts +1 -1
  115. package/src/insights.ts +174 -0
  116. package/src/issues.ts +396 -0
  117. package/src/judge-verdict.ts +52 -0
  118. package/src/library.ts +1 -1
  119. package/src/limits.ts +38 -5
  120. package/src/linkable.ts +1 -1
  121. package/src/matter.ts +145 -0
  122. package/src/model-credential.ts +45 -0
  123. package/src/network.ts +35 -7
  124. package/src/notify.ts +22 -7
  125. package/src/okf.ts +1 -1
  126. package/src/online-checks.ts +450 -0
  127. package/src/operator.ts +213 -3
  128. package/src/otel-config.ts +67 -0
  129. package/src/otel.ts +100 -0
  130. package/src/outcome.ts +90 -0
  131. package/src/package.ts +8 -2
  132. package/src/platform.ts +1 -1
  133. package/src/prompt-layers.ts +90 -0
  134. package/src/provider-health.ts +2 -2
  135. package/src/providers.ts +7 -2
  136. package/src/run-container.ts +77 -8
  137. package/src/runner.ts +477 -79
  138. package/src/runs-filter.ts +88 -0
  139. package/src/scan.ts +153 -14
  140. package/src/starter.ts +27 -2
  141. package/src/step-exec.ts +168 -17
  142. package/src/store.ts +268 -9
  143. package/src/tokens.ts +68 -0
  144. package/src/tool-names.ts +1 -1
  145. package/src/tool-programs.ts +1 -1
  146. package/src/trace-tree.ts +187 -0
  147. package/src/translator.ts +13 -2
  148. package/src/trigger-log.ts +1 -0
  149. package/src/workspace-check.ts +41 -0
package/src/runner.ts CHANGED
@@ -3,11 +3,14 @@
3
3
  // fail without failing the flow. A direct agent run is a one-step ad-hoc
4
4
  // flow, so there is a single execution path.
5
5
 
6
+ import { latestRevisionId } from "./history.ts";
7
+ import { recordCallIo, redactionsFor } from "./capture.ts";
8
+ import { AUTH_REFUSAL } from "./issues.ts";
6
9
  import { hookNames } from "./hooks.ts";
7
10
  import fs from "node:fs";
8
11
  import os from "node:os";
9
12
  import path from "node:path";
10
- import matter from "gray-matter";
13
+ import matter, { parseLenient, configMatter } from "./matter.ts";
11
14
  import { spawn } from "node:child_process";
12
15
  import {
13
16
  knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor, type EventExtra } from "./step-exec.ts";
@@ -17,7 +20,8 @@ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.ts
17
20
  import { eventUrl } from "./webhook.ts";
18
21
  import { runStepInContainer, sizeLimits, killRunSandboxes, type StepTiming } from "./run-container.ts";
19
22
  import { hostSafeEnv, platformApiUrl } from "./host-env.ts";
20
- import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
23
+ import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.ts";
24
+ import { parseJudge } from "./judge-verdict.ts";
21
25
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
22
26
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
23
27
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
@@ -33,7 +37,7 @@ const isolatedRun = () => {
33
37
  };
34
38
  import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
35
39
  import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools, type SubagentSpec } from "./subagents.ts";
36
- import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.ts";
40
+ import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine, type PeerInfo } from "./operator.ts";
37
41
  import { sendPlainNotification } from "./notify.ts";
38
42
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.ts";
39
43
  import { refNames } from "./refs.ts";
@@ -67,6 +71,7 @@ import {
67
71
  checkFormatVersion,
68
72
  readRun,
69
73
  writeRun,
74
+ isRescueRecord,
70
75
  runSummary,
71
76
  verdictOf,
72
77
  type FlowStep,
@@ -83,9 +88,10 @@ import {
83
88
  runCost,
84
89
  } from "./store.ts";
85
90
  import { canReadBack } from "./tool-output.ts";
91
+ import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
86
92
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
87
93
  import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
88
- import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
94
+ import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
89
95
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
90
96
  import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
91
97
  import { attachOperations, prefetchOpenApi } from "./openapi.ts";
@@ -210,6 +216,101 @@ export function slotPool(size: number): { acquire: () => Promise<void>; release:
210
216
  };
211
217
  }
212
218
 
219
+ /** Steps whose model has begun its first response — the step's executor
220
+ * says so the moment the stream starts (EventExtra.responding). Memory
221
+ * only: it is scheduling, not something the run record needs. */
222
+ const respondingSteps = new WeakSet<StepRecord>();
223
+ export function markResponding(step: StepRecord): void {
224
+ respondingSteps.add(step);
225
+ }
226
+
227
+ /**
228
+ * Is this step's prompt in the cache yet? An entry is readable from the
229
+ * moment the response to the request that wrote it begins streaming
230
+ * (Anthropic's prompt-caching docs), which is what `responding` marks.
231
+ * The first text, tool call or recorded model call says the same a little
232
+ * later, for an executor that cannot see the stream (the stub, an older
233
+ * driver). Resolves on the first of those, or when the step is over
234
+ * without one (a refusal before the first turn, a failed start).
235
+ */
236
+ export function untilFirstReply(step: StepRecord, done: Promise<unknown>, pollMs = 250): Promise<void> {
237
+ const replied = () =>
238
+ respondingSteps.has(step) || step.events.some((e) => e.type === "text" || e.type === "tool") || (step.calls?.length ?? 0) > 0;
239
+ return new Promise<void>((resolve) => {
240
+ let finished = false;
241
+ const stop = () => {
242
+ finished = true;
243
+ resolve();
244
+ };
245
+ done.then(stop, stop);
246
+ const tick = () => {
247
+ if (finished) return;
248
+ if (replied()) return stop();
249
+ setTimeout(tick, pollMs).unref?.();
250
+ };
251
+ tick();
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Steps of one agent launched together share a prompt prefix — tools and
257
+ * system prompt — and that is what the model's cache keeps. The cache is
258
+ * written by the first request that carries a prefix and readable only
259
+ * once that response has begun, so N copies started at once all write it
260
+ * and none reads: a fan-out of twenty paid twenty cold prefixes. The gate
261
+ * lets the first instance of each agent go alone and holds its siblings
262
+ * until its first response has begun (or it ended); then they all start
263
+ * and read what it wrote — Claude Code holds a workflow's fan-out the same
264
+ * way. Not a fixed delay: a sandbox takes seconds to start before its
265
+ * first model call, so a timer from launch would release the siblings
266
+ * before there was anything to read. Keyed by agent and model, since a
267
+ * cache is per model.
268
+ */
269
+ export function warmGate(launching: StepRecord[], pollMs = 250) {
270
+ const key = (s: StepRecord) => `${s.agent}\u0000${s.model ?? ""}`;
271
+ const counts = new Map<string, number>();
272
+ for (const s of launching) counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
273
+ const leads = new Map<string, { opened: Promise<void>; open: () => void }>();
274
+ const isLead = new WeakSet<StepRecord>();
275
+ return {
276
+ /** Claim the step's place — synchronously, before anything awaits, or
277
+ * the copies that reach their first await together would all find no
278
+ * lead and all go (a `parallel:` pool's acquire was exactly that).
279
+ * The first of its agent leads and gets null; a later one gets the
280
+ * lead's gate to await before it starts; a lone step gets null too. */
281
+ claim(step: StepRecord): Promise<void> | null {
282
+ const k = key(step);
283
+ if ((counts.get(k) ?? 0) < 2) return null;
284
+ const lead = leads.get(k);
285
+ if (lead) return lead.opened;
286
+ let open!: () => void;
287
+ const opened = new Promise<void>((r) => (open = r));
288
+ leads.set(k, { opened, open });
289
+ isLead.add(step);
290
+ return null;
291
+ },
292
+ /** A lead that will not run after all (the run was stopped while it
293
+ * waited for a pool slot) still opens its gate — or its siblings,
294
+ * which also wake to the stop, would wait on it forever. */
295
+ release(step: StepRecord): void {
296
+ if (isLead.has(step)) leads.get(key(step))!.open();
297
+ },
298
+ /** Run the step; a lead opens its agent's gate on its first reply (or
299
+ * its end), whatever else happens to it. */
300
+ async run(step: StepRecord, launch: () => Promise<void>): Promise<void> {
301
+ if (!isLead.has(step)) return launch();
302
+ const { open } = leads.get(key(step))!;
303
+ const done = launch();
304
+ try {
305
+ await untilFirstReply(step, done, pollMs);
306
+ } finally {
307
+ open();
308
+ }
309
+ await done;
310
+ },
311
+ };
312
+ }
313
+
213
314
  export interface DiscoveredSkill {
214
315
  name: string;
215
316
  description: string;
@@ -235,32 +336,10 @@ const toTags = (v: unknown): string[] =>
235
336
  // The Agent Skills client guide recommends quoting such values and retrying
236
337
  // rather than dropping the skill, so a cross-client skill still loads here.
237
338
  function safeMatter(raw: string): { data: Record<string, unknown> } {
238
- // gray-matter caches by input string AND caches a *failed* parse: the first
239
- // call on invalid YAML throws, but every later call on the same bytes returns
240
- // a hollow object with the fields silently missing. Clearing the cache makes
241
- // the throw deterministic, so the fallback below fires every time rather than
242
- // once — otherwise the same skill loads on one run and vanishes on the next.
243
- (matter as unknown as { clearCache: () => void }).clearCache();
244
- try {
245
- return matter(raw) as { data: Record<string, unknown> };
246
- } catch {
247
- const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
248
- if (fm) {
249
- const fixed = fm[1].replace(
250
- /^([A-Za-z0-9_-]+):[ \t]+(?!["'>|])(.*:.*)$/gm,
251
- (_m, k: string, v: string) => `${k}: ${JSON.stringify(v)}`,
252
- );
253
- try {
254
- // A function replacer, not a string: a fixed block containing `$&` or
255
- // `$$` (a value with those in it) would otherwise be treated as a
256
- // replacement pattern and splice the whole frontmatter into itself.
257
- return matter(raw.replace(fm[1], () => fixed)) as { data: Record<string, unknown> };
258
- } catch {
259
- /* fall through */
260
- }
261
- }
262
- return { data: {} };
263
- }
339
+ // gray-matter caches a *failed* parse as a hollow success; ./matter.ts
340
+ // parses every call, so the strict read throws every time and the lenient
341
+ // one (shared with check, which warns about it) fires every time.
342
+ return { data: parseLenient(raw).data };
264
343
  }
265
344
 
266
345
  // Both layouts: <root>/<name>/SKILL.md (Agent Skills standard) and the flat
@@ -356,7 +435,7 @@ export function readAgentsMd(dir: string): { data: Record<string, unknown>; body
356
435
  const file = path.join(dir, name);
357
436
  if (!fs.existsSync(file)) continue;
358
437
  try {
359
- const parsed = matter(fs.readFileSync(file, "utf8"));
438
+ const parsed = configMatter(fs.readFileSync(file, "utf8"));
360
439
  return { data: parsed.data as Record<string, unknown>, body: withoutAgentRules(parsed.content).trim() };
361
440
  } catch {
362
441
  return null;
@@ -427,6 +506,10 @@ export function workspaceRuntimePlan(
427
506
  return [...byFp.values()];
428
507
  }
429
508
 
509
+ /** The most tool content one step records (FOL-54 step 4): about 60 calls
510
+ * at the per-part cap; past it, metadata only. */
511
+ const STEP_CAPTURE_LIMIT = 4_000_000;
512
+
430
513
  function workspaceFrontmatter(agentDir: string, tenant: string): Record<string, unknown> {
431
514
  return {
432
515
  ...(readAgentsMd(accountDir(tenant))?.data ?? {}),
@@ -755,10 +838,14 @@ export function agentContext(
755
838
  // agentDir is <data>/<tenant>/projects/<workspace>/agents/<agent>, so the
756
839
  // workspace a secret should resolve against is two levels up.
757
840
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
758
- const { data: front, content: body } = matter(
841
+ const { data: front, content: body } = configMatter(
759
842
  fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"),
760
843
  );
761
- const parts = [resolveDocLinks(body.trim(), path.resolve(agentDir, "..", ".."))];
844
+ // The prompt's sections, by name and by how often they change. Added in
845
+ // the order they are computed; assembled stable first, so the model's
846
+ // cache keeps the agent's half across steps and runs (prompt-layers.ts).
847
+ const parts = new PromptLayers();
848
+ parts.add("persona", resolveDocLinks(body.trim(), path.resolve(agentDir, "..", "..")));
762
849
 
763
850
  // Where the agent is standing. Every path below this line is relative to the
764
851
  // agent's own directory, and a run showed why that has to be said rather
@@ -780,14 +867,14 @@ export function agentContext(
780
867
  const languageSet = language.from !== "default" && language.from !== "env";
781
868
  const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
782
869
  if (languageSet || regionSet) {
783
- parts.push(
870
+ parts.add("locale",
784
871
  `# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
785
872
  `Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
786
873
  `use these currency, units and calendar conventions in anything you produce. ` +
787
874
  `Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`,
788
875
  );
789
876
  }
790
- parts.push(
877
+ parts.add("where",
791
878
  `# Where you are\n\n` +
792
879
  `Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
793
880
  `\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
@@ -832,7 +919,7 @@ export function agentContext(
832
919
  // Shared context before anything derived — an account or workspace rule is
833
920
  // background the rest of the prompt is read against, not an afterthought.
834
921
  const sharedContext = sharedInstructions(agentDir, tenant);
835
- if (sharedContext) parts.push(sharedContext);
922
+ if (sharedContext) parts.add("agents.md", sharedContext);
836
923
 
837
924
  // Skills follow the open Agent Skills format (agentskills.io): a folder
838
925
  // with SKILL.md, optionally bundling its own scripts/ and references/. The
@@ -871,7 +958,7 @@ export function agentContext(
871
958
  (bundles.length ? ` (also bundles ${bundles.join(", ")})` : "")
872
959
  );
873
960
  });
874
- parts.push(
961
+ parts.add("skills",
875
962
  `# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
876
963
  `read its file for the full instructions before proceeding:\n\n${lines.join("\n")}` +
877
964
  (withheld ? `\n\n(${withheld} further skills exist but do not apply to this run.)` : ""),
@@ -893,11 +980,13 @@ export function agentContext(
893
980
  : "";
894
981
  const projectMem = workspaceMemoryIndex(tenant, workspace);
895
982
  const shared = libraryMemoryIndex(tenant);
896
- parts.push(
983
+ // Dynamic: agents write memory, so the index moves between runs.
984
+ parts.add("memory",
897
985
  `# Memory — what you have learned\n\n${memoryIndex ? `Your own (files under memory/):\n\n${memoryIndex}` : "You have no memories of your own yet."}` +
898
986
  (projectMem ? `\n\nShared across this workspace:\n\n${projectMem}` : "") +
899
987
  (shared ? `\n\nShared across every workspace:\n\n${shared}` : "") +
900
988
  howTo,
989
+ { dynamic: true },
901
990
  );
902
991
  }
903
992
 
@@ -914,7 +1003,7 @@ export function agentContext(
914
1003
  projectKnowledge && `Shared across this workspace:\n\n${projectKnowledge}`,
915
1004
  sharedKnowledge && `Shared across every workspace:\n\n${sharedKnowledge}`,
916
1005
  ].filter(Boolean);
917
- parts.push(
1006
+ parts.add("knowledge",
918
1007
  `# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
919
1008
  `on it rather than guessing at its contents. This is read-only: it is maintained by ` +
920
1009
  `people, not by you — if you learn something new, write it to memory/ instead.\n\n` +
@@ -947,7 +1036,7 @@ export function agentContext(
947
1036
  shared.length ? `Shared across this workspace:\n${shared.join("\n")}` : null,
948
1037
  global.length ? `From the account library (read-only):\n${global.join("\n")}` : null,
949
1038
  ].filter(Boolean);
950
- parts.push(
1039
+ parts.add("scripts",
951
1040
  `# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
952
1041
  `Read a script before running it if you are unsure what it does. Credentials your ` +
953
1042
  `scripts need are already in the environment as the secrets listed below.`,
@@ -965,7 +1054,8 @@ export function agentContext(
965
1054
  // file to size it against, not before.
966
1055
  const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
967
1056
  if (state.length) {
968
- parts.push(
1057
+ // Dynamic: runs add and remove state files.
1058
+ parts.add("state",
969
1059
  `# State — what you carry between runs\n\n` +
970
1060
  `What this workspace keeps across runs: a count, a cursor, where you got to. ` +
971
1061
  `Read and write these directly, and update them before you finish so the next ` +
@@ -975,6 +1065,7 @@ export function agentContext(
975
1065
  `same editor as everything else — data formats are allowed here, not preferred.\n\n` +
976
1066
  `This is not memory. A fact you learned goes in memory/, where it is indexed and ` +
977
1067
  `its provenance recorded — state is the bookmark, not the lesson.`,
1068
+ { dynamic: true },
978
1069
  );
979
1070
  }
980
1071
 
@@ -1057,7 +1148,7 @@ export function agentContext(
1057
1148
  const keepIn = { dir: agentDir, readable: () => readsFiles };
1058
1149
  const apiTools = buildApiTools(tenant, apis, workspace, undefined, keepIn);
1059
1150
  if (apiTools.promptLines.length) {
1060
- parts.push(`# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
1151
+ parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
1061
1152
  }
1062
1153
 
1063
1154
  // Secrets an agent declared become env vars for its scripts and bash.
@@ -1214,6 +1305,10 @@ export function agentContext(
1214
1305
  name: spec.name,
1215
1306
  maxTokensParam: preset?.maxTokensParam,
1216
1307
  reasoningEffort: preset?.reasoningEffort,
1308
+ // The agent's prompts share an opening; the key says so to an
1309
+ // endpoint that routes its cache by one. Only where the preset
1310
+ // says the parameter is understood — elsewhere it is a 400.
1311
+ cacheKey: preset?.promptCacheKey ? promptCacheKey(tenant, workspace, path.basename(agentDir)) : undefined,
1217
1312
  });
1218
1313
  // What the run is actually pointed at, in one line: the endpoint, the
1219
1314
  // tiers this gateway renames, and whether the credential landed.
@@ -1404,12 +1499,15 @@ export function agentContext(
1404
1499
  exec,
1405
1500
  keepIn.readable,
1406
1501
  );
1407
- parts.push(
1502
+ // Dynamic: the date moves daily, and would otherwise invalidate every
1503
+ // layer behind it once a day.
1504
+ parts.add("clock",
1408
1505
  `# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
1409
1506
  `write or check — it is the workspace's calendar, not the container's clock.`,
1507
+ { dynamic: true },
1410
1508
  );
1411
1509
  if (scriptTools.promptLines.length) {
1412
- parts.push(
1510
+ parts.add("script-tools",
1413
1511
  `# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
1414
1512
  `\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`,
1415
1513
  );
@@ -1420,20 +1518,20 @@ export function agentContext(
1420
1518
  // reddit-desk validator step on 6 Oct 2026. Say it before the first call.
1421
1519
  if (builtins.length) {
1422
1520
  const named = builtins.map((b) => b.split("="));
1423
- parts.push(
1521
+ parts.add("web",
1424
1522
  `# Web search and fetch on this run\n\n` +
1425
1523
  named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
1426
1524
  `\n\nThe web tool's other actions work as usual.`,
1427
1525
  );
1428
1526
  }
1429
1527
  if (declared.length) {
1430
- parts.push(
1528
+ parts.add("secrets",
1431
1529
  `# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
1432
1530
  `Never print, echo, or write them to a file.`,
1433
1531
  );
1434
1532
  }
1435
1533
 
1436
- parts.push(
1534
+ parts.add("rules",
1437
1535
  "Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
1438
1536
  "what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
1439
1537
  "for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
@@ -1453,7 +1551,7 @@ export function agentContext(
1453
1551
  // run, and an agent that opened with "I'll start by cloning the repo" made
1454
1552
  // all three useless. Asking here rather than in each agent.md means every
1455
1553
  // agent obeys it, including the ones written before the summary existed.
1456
- parts.push(
1554
+ parts.add("rules",
1457
1555
  "Open your reply with one sentence saying what you concluded or produced — the outcome, " +
1458
1556
  "not your plan. Everything else follows it. That sentence is what a person sees in the " +
1459
1557
  "run list and in the notification, without opening the run.",
@@ -1500,6 +1598,7 @@ export function agentContext(
1500
1598
  let wantHistory = false;
1501
1599
  let wantDesks = false;
1502
1600
  let wantAsk = false;
1601
+ let wantPeers = false;
1503
1602
 
1504
1603
  for (const ref of toolRefs(front)) {
1505
1604
  const toolName = ref.name;
@@ -1532,12 +1631,13 @@ export function agentContext(
1532
1631
  } else if (TOOL_MAP[toolName]) {
1533
1632
  allowed.push(...TOOL_MAP[toolName]);
1534
1633
  if (available[toolName]) shadowed.push(toolName);
1535
- } else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
1634
+ } else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask" || toolName === "peers") {
1536
1635
  // The platform's own groups, served in-process — built below, once
1537
1636
  // the roots and the records are known.
1538
1637
  if (toolName === "search") wantSearch = true;
1539
1638
  else if (toolName === "history") wantHistory = true;
1540
1639
  else if (toolName === "ask") wantAsk = true;
1640
+ else if (toolName === "peers") wantPeers = true;
1541
1641
  else wantDesks = true;
1542
1642
  if (available[toolName]) shadowed.push(toolName);
1543
1643
  } else if (toolName === "install") {
@@ -1593,10 +1693,16 @@ export function agentContext(
1593
1693
  const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
1594
1694
  if (ask) {
1595
1695
  allowed.push(ASK_TOOL);
1596
- parts.push(`# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1696
+ parts.add("ask", `# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1597
1697
  }
1698
+ // tools: [peers] — peers_list and peers_message, to the other steps of
1699
+ // this step's parallel group. Who they are is known only when the group
1700
+ // launches, so the grant is here and the roster, the server and the
1701
+ // prompt's line are the step's (runStep).
1702
+ const peers = wantPeers;
1703
+ if (peers) allowed.push(...PEER_TOOLS);
1598
1704
  if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
1599
- parts.push(`# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
1705
+ parts.add("finding", `# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
1600
1706
  }
1601
1707
  // An MCP server declares its tools when it connects, so they can't be listed
1602
1708
  // ahead of time. Server-level specs cover them: granting the server grants
@@ -1618,6 +1724,7 @@ export function agentContext(
1618
1724
  if (denied.includes("install")) denied.push(INSTALL_TOOL);
1619
1725
  const finalAllowed = allowed.filter((t) => !denied.includes(t));
1620
1726
  readsFiles = canReadBack(finalAllowed);
1727
+ const prompt = parts.assemble();
1621
1728
 
1622
1729
  return {
1623
1730
  front,
@@ -1625,7 +1732,9 @@ export function agentContext(
1625
1732
  /** The zone, where it came from, and anything unreadable on the way —
1626
1733
  * reported by the step, not here, so it lands in the run's trace. */
1627
1734
  clock,
1628
- systemPrompt: parts.join("\n\n"),
1735
+ systemPrompt: prompt.text,
1736
+ /** What the record keeps of each layer of that prompt. */
1737
+ promptLayers: prompt.layers,
1629
1738
  allowed: finalAllowed,
1630
1739
  disabled,
1631
1740
  unknownTools,
@@ -1649,6 +1758,7 @@ export function agentContext(
1649
1758
  historyTools,
1650
1759
  deskTools,
1651
1760
  ask,
1761
+ peers,
1652
1762
  // `spec` is the MERGED declaration — the agent's own block or its
1653
1763
  // workspace's, plus every granted tool's. The isolated path builds its
1654
1764
  // runtime from this; passing the agent's frontmatter alone silently drops
@@ -1906,6 +2016,12 @@ async function runStep(
1906
2016
  * person's message to this step reaches this step's own inbox and not a
1907
2017
  * sibling's running in the same group. Undefined outside a flow. */
1908
2018
  stepIndex?: number,
2019
+ /** The other steps launched with this one (its parallel group), for
2020
+ * tools: [peers]. Empty when it runs alone. */
2021
+ siblings: PeerInfo[] = [],
2022
+ /** The in-process colleagues' bus for this group (localPeerBus), when
2023
+ * the step runs in this process rather than a pod. */
2024
+ peerBus?: ReturnType<typeof localPeerBus>,
1909
2025
  ) {
1910
2026
  // Secret values are injected into scripts as environment variables and
1911
2027
  // substituted into API headers, so a model that reads one back — from a
@@ -1952,9 +2068,63 @@ async function runStep(
1952
2068
  }
1953
2069
  };
1954
2070
 
2071
+ // What this workspace records of each step (FOL-54 step 4): `capture:` in
2072
+ // AGENTS.md — every model call always; each tool call's input and output
2073
+ // unless it says `metadata`.
2074
+ const capture: "metadata" | "full" = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
2075
+ // `judge:` (AGENTS.md): the model and effort every `verify: judge:` here
2076
+ // grades with — field by field, the workspace's else the account's.
2077
+ const { judgeModel, judgeEffort } = (() => {
2078
+ const ws = parseJudge(readAgentsMd(workspaceRootOf(agentDir))?.data?.judge);
2079
+ const account = parseJudge(readAgentsMd(accountDir(tenant))?.data?.judge);
2080
+ const model = ws.model ?? account.model;
2081
+ const effort = resolveEffort(ws.effort ?? account.effort ?? "");
2082
+ return { judgeModel: model ? resolveModel(model) : undefined, judgeEffort: effort ?? undefined };
2083
+ })();
2084
+ // A step that pours out content stops being recorded past this — its
2085
+ // calls keep their names, sizes and times (FOL-54 step 4).
2086
+ let capturedChars = 0;
2087
+ let captureFull = false;
1955
2088
  const push = (type: StepRecord["events"][number]["type"], text: string, extra?: EventExtra) => {
1956
2089
  const t = new Date().toISOString();
1957
- step.events.push({ t, type, text: redact(text), ...extra });
2090
+ // The model has begun its first response: held only in memory, for
2091
+ // the fan-out gate (warmGate). Not a line in the trace.
2092
+ if (extra?.responding) {
2093
+ markResponding(step);
2094
+ return;
2095
+ }
2096
+ // A model call is a span on the step, not a line in its trace.
2097
+ // Numbered here, across attempts: a retry's calls follow the first
2098
+ // attempt's, and an id-less turn of one attempt never overwrites
2099
+ // another's. No save of its own — a turn's blocks arrive as several
2100
+ // messages, and the next event saves them all.
2101
+ if (extra?.turn) {
2102
+ const calls = (step.calls ??= []);
2103
+ // The loop counts attempts from 1; a call of the first carries none.
2104
+ const attempt = Math.max(1, step.attempts ?? 1);
2105
+ const at = calls.findIndex((c) => c.id === extra.turn!.id && (c.attempt ?? 1) === attempt);
2106
+ const call = { ...extra.turn, n: at >= 0 ? calls[at]!.n : calls.length + 1, ...(attempt > 1 ? { attempt } : {}) };
2107
+ if (at >= 0) calls[at] = call;
2108
+ else calls.push(call);
2109
+ return;
2110
+ }
2111
+ // A tool call's content goes beside the run, masked (capture.ts), and the
2112
+ // event keeps only that it was recorded.
2113
+ let rest: EventExtra | undefined = extra;
2114
+ if (extra?.io) {
2115
+ const { io, ...others } = extra;
2116
+ rest = others;
2117
+ const size = (io.input?.length ?? 0) + (io.output?.length ?? 0);
2118
+ if (runId && extra.call && capture === "full" && capturedChars + size > STEP_CAPTURE_LIMIT && !captureFull) {
2119
+ captureFull = true;
2120
+ step.events.push({ t, type: "info", text: `capture: this step recorded ${Math.round(capturedChars / 1000)}k characters of tool content — the rest of its calls keep only their names, sizes and times` });
2121
+ }
2122
+ if (runId && extra.call && capture === "full" && !captureFull && recordCallIo(path.resolve(agentDir, "..", ".."), runId, extra.call, text, io, redact)) {
2123
+ capturedChars += size;
2124
+ rest = { ...others, captured: true };
2125
+ }
2126
+ }
2127
+ step.events.push({ t, type, text: redact(text), ...rest });
1958
2128
  // A person in the loop: the question, its answer, a message delivered —
1959
2129
  // onto the step, so the run page, the API and history show them.
1960
2130
  if (extra?.operator) {
@@ -1988,7 +2158,26 @@ async function runStep(
1988
2158
  step.status = "completed";
1989
2159
  // A stub answer may open with `cost: 0.25` — a pretend price, so the
1990
2160
  // parts of orchestration that watch money (budget:) are testable too.
1991
- const answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
2161
+ let answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
2162
+ // `call: <tool> <input> => <output>` lines: a pretend tool call, sent
2163
+ // through the same push a real step's events take — with its model call
2164
+ // and its content — so what the runner records of a step is testable.
2165
+ answer = answer
2166
+ .split("\n")
2167
+ .filter((line) => {
2168
+ const m = /^call: (\S+) (.*?) => (.*)$/.exec(line);
2169
+ if (!m) return true;
2170
+ const id = `toolu_stub${(step.calls?.length ?? 0) + 1}`;
2171
+ // Twice under one id, as the SDK sends a turn's blocks: one row.
2172
+ const turn = { id: `msg_stub${(step.calls?.length ?? 0) + 1}`, n: 0, at: new Date().toISOString(), ms: 5, model: "stub", tokens: { input: 100, output: 10, cacheRead: 80, cacheWrite: 0 }, tools: 0 };
2173
+ push("info", "", { turn });
2174
+ push("info", "", { turn: { ...turn, stop: "tool_use", tools: 1 } });
2175
+ push("tool", m[1]!, { call: id, ...(capture ? { io: { input: m[2]!, inputChars: m[2]!.length } } : {}) });
2176
+ push("tool", m[1]!, { call: id, ms: 3, io: { output: m[3]!, outputChars: m[3]!.length } });
2177
+ return false;
2178
+ })
2179
+ .join("\n")
2180
+ .trim();
1992
2181
  const priced = answer.match(/^cost:\s*([\d.]+)\n?/);
1993
2182
  const body = (priced ? answer.slice(priced[0].length) : answer).trim();
1994
2183
  // A `===` line inside an answer separates earlier turns from the final
@@ -2027,7 +2216,7 @@ async function runStep(
2027
2216
  // step: the generic HTTP tool still works without the typed ones.
2028
2217
  for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant))) push("error", `openapi: ${w}`);
2029
2218
  const {
2030
- front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools,
2219
+ front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools,
2031
2220
  secretEnv, secretScopes, missingSecrets, missingTools, runtime,
2032
2221
  unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames,
2033
2222
  apiSpecs, scriptSpecs, brokenTools, size: agentSize,
@@ -2035,6 +2224,7 @@ async function runStep(
2035
2224
  searchChoice, fetchChoice, actionChoices, web, language, region,
2036
2225
  fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools,
2037
2226
  ask,
2227
+ peers,
2038
2228
  translator, fallbackTranslator,
2039
2229
  outward,
2040
2230
  } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
@@ -2043,6 +2233,9 @@ async function runStep(
2043
2233
  // Sent again on every turn; with turns and the tools' result sizes, what
2044
2234
  // a token report reads to say where a step's input went.
2045
2235
  step.systemPromptChars = systemPrompt.length;
2236
+ // And each layer's size and hash, so a later reader can say which part
2237
+ // of the prompt changed between this step and the agent's last.
2238
+ step.promptLayers = promptLayers;
2046
2239
  // A retry that moved the step up a class (an evicted or OOM-killed
2047
2240
  // attempt) wins over the agent's own `size:` for the attempts after.
2048
2241
  const size = step.sizeUp ?? agentSize;
@@ -2062,11 +2255,7 @@ async function runStep(
2062
2255
  // Populate before the first push: everything after this point may quote a
2063
2256
  // credential. Short values are skipped — a two-character secret would
2064
2257
  // redact half the English language, and anything that short is not one.
2065
- redactions = Object.entries({ ...liveSecrets, ...providerSecrets, ...providerEnv })
2066
- .filter(([, value]) => typeof value === "string" && value.length >= 8)
2067
- .map(([name, value]) => [value, name] as [string, string])
2068
- // Longest first, so a value containing another is replaced whole.
2069
- .sort((a, b) => b[0].length - a[0].length);
2258
+ redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
2070
2259
 
2071
2260
  // One line for the clock: which zone this step works to, and which level
2072
2261
  // set it. "Why is this dated yesterday" should be answerable from the run.
@@ -2314,7 +2503,14 @@ async function runStep(
2314
2503
  ...mcpNames,
2315
2504
  ...(consults.length ? ["foldrun_agents"] : []),
2316
2505
  ...(ask ? ["foldrun_ask"] : []),
2506
+ ...(peers ? ["foldrun_peers"] : []),
2317
2507
  ];
2508
+ if (peers) {
2509
+ // The colleagues' contract, in the prompt the step reads: who runs
2510
+ // beside it this time (none, when it runs alone), and that a message
2511
+ // is a request, never a hand-over.
2512
+ prompt += `\n\n<colleagues>\n${peersPromptLine(siblings)}\n</colleagues>`;
2513
+ }
2318
2514
 
2319
2515
  // Colleagues this agent may DELEGATE to: each with its own context and
2320
2516
  // its own tools — resolved exactly as its own step's would be, then cut
@@ -2483,7 +2679,24 @@ async function runStep(
2483
2679
  ],
2484
2680
  });
2485
2681
  grant.network = { level: network.level, hosts: network.hosts };
2486
- const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
2682
+ // network_enforce: true at the account or the workspace asks the proxy
2683
+ // to refuse; the proxy adds the install's own switch and has the last
2684
+ // word (egress-proxy.ts), so false here never loosens anything.
2685
+ grant.enforce = networkEnforceWanted(
2686
+ readAgentsMd(accountDir(tenant))?.data?.network_enforce,
2687
+ readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce,
2688
+ );
2689
+ const lease = await platform.egress.lease({
2690
+ tenant,
2691
+ runId: runId ?? "adhoc",
2692
+ grant,
2693
+ test: testRun,
2694
+ ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
2695
+ // tools: [peers] — the roster the proxy serves, and the inboxes a
2696
+ // colleague's message may be addressed to. Only when granted: a
2697
+ // step without the tools has no reason to know its siblings.
2698
+ ...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
2699
+ });
2487
2700
  // The forward door: every program in the sandbox sends through the
2488
2701
  // proxy, which holds the site list and fills bound secrets. A proxy
2489
2702
  // without the door (an older platform) leaves programs going direct.
@@ -2556,6 +2769,23 @@ async function runStep(
2556
2769
  // carrying TEST_MODE_WITHHELD would only fail where it should have
2557
2770
  // been sunk or refused.
2558
2771
  const apiValues = testRun && lease ? placeholders : sandboxSecrets;
2772
+ // A declared secret named like the step's own model variables
2773
+ // (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, …) was spread over them below
2774
+ // and broke, or redirected, the step's model calls (FOL-19). It crosses
2775
+ // as FOLDRUN_DECLARED_<NAME> instead: the model keeps its settings, and
2776
+ // the driver gives the agent's API and script tools the value under its
2777
+ // own name (run-container.ts) — never the model's key in its place.
2778
+ const modelNamed = Object.keys(sandboxSecrets).filter(isModelEnvName);
2779
+ if (modelNamed.length) {
2780
+ push(
2781
+ "info",
2782
+ `secrets: ${modelNamed.join(", ")} kept away from the model's own settings — ${modelNamed.length === 1 ? "that name is" : "those names are"} what the step's model call uses; ` +
2783
+ "its script and API tools and verify: get the declared value, its own shell the model's. Give the secret another name, or run the model on it with provider:",
2784
+ );
2785
+ }
2786
+ const sandboxEnvSecrets = Object.fromEntries(
2787
+ Object.entries(sandboxSecrets).map(([name, value]) => [isModelEnvName(name) ? `${DECLARED_ENV_PREFIX}${name}` : name, value]),
2788
+ );
2559
2789
  const substitutedApis = apiSpecs.map((api) => ({
2560
2790
  ...api,
2561
2791
  headers: Object.fromEntries(
@@ -2612,15 +2842,19 @@ async function runStep(
2612
2842
  // field. A tool's own `runtime:` is part of what this step needs.
2613
2843
  runtime: runtime.spec,
2614
2844
  consults,
2845
+ ...(peers ? { peers: { self: step.agent, siblings } } : {}),
2615
2846
  ...(subagents.length ? { subagents } : {}),
2616
2847
  timeoutSec: step.timeout,
2617
2848
  budgetUsd: stepBudgetUsd,
2618
2849
  budgetNote: stepBudgetNote,
2619
2850
  price: tokenPrice,
2620
2851
  verify: step.verify,
2852
+ ...(judgeModel ? { judgeModel } : {}),
2853
+ ...(judgeEffort ? { judgeEffort } : {}),
2621
2854
  output: step.output,
2622
2855
  schema: resolvedSchema ?? undefined,
2623
2856
  maxTurns: step.maxTurns,
2857
+ capture,
2624
2858
  ...limitOpts,
2625
2859
  ...hookOpts,
2626
2860
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
@@ -2667,12 +2901,15 @@ async function runStep(
2667
2901
  FOLDRUN_AGENT: step.agent,
2668
2902
  FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
2669
2903
  ...(testRun ? testModeEnv() : {}),
2670
- ...sandboxSecrets,
2904
+ ...sandboxEnvSecrets,
2671
2905
  }).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
2672
2906
  ),
2673
2907
  emit: pushWatching,
2674
2908
  // Stamps the sandbox, so stopping this run can destroy it.
2675
2909
  runId,
2910
+ // A step still waiting for room has no sandbox to destroy; the
2911
+ // executor asks this before it creates one (pod-room.ts).
2912
+ ...(stopRequested ? { stopRequested } : {}),
2676
2913
  size,
2677
2914
  // The pod's network, for the executor's own wall (the k8s tier's
2678
2915
  // network policy): a filtered pod reaches the proxy and DNS only.
@@ -2950,6 +3187,13 @@ async function runStep(
2950
3187
  // On this machine nobody can write in and there is no proxy: the
2951
3188
  // question goes to the terminal when there is one, else unanswered.
2952
3189
  const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
3190
+ // Colleagues in the same group reach each other over the run's own
3191
+ // bus here — no proxy, so no person can write in, but a sibling can.
3192
+ // Every grouped step gets the inbox (a pod reads the proxy's inbox
3193
+ // whatever its tools); only tools: [peers] gets the tools to send.
3194
+ const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
3195
+ const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
3196
+ const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
2953
3197
  // @file secrets become 0600 paths here (host run), cleaned up in the
2954
3198
  // finally — once, however many attempts follow.
2955
3199
  const mat = materializeFileSecrets(agentDir, stepSecrets);
@@ -2973,6 +3217,9 @@ async function runStep(
2973
3217
  workspaceRoot,
2974
3218
  libraryRoot: libraryDir(tenant),
2975
3219
  ...(subagents.length ? { subagents } : {}),
3220
+ // The colleagues' inbox, read after every tool call — the same hook
3221
+ // a pod uses for a person's messages.
3222
+ ...(localInbox ? { inbox: localInbox } : {}),
2976
3223
  prompt,
2977
3224
  model,
2978
3225
  effort,
@@ -2987,6 +3234,7 @@ async function runStep(
2987
3234
  ...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
2988
3235
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
2989
3236
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
3237
+ ...(peerTools ? { foldrun_peers: peerTools.server } : {}),
2990
3238
  ...(installTools ? { foldrun_install: installTools.server } : {}),
2991
3239
  ...mcpServers,
2992
3240
  },
@@ -3001,9 +3249,12 @@ async function runStep(
3001
3249
  budgetNote: stepBudgetNote,
3002
3250
  price: tokenPrice,
3003
3251
  verify: step.verify,
3252
+ ...(judgeModel ? { judgeModel } : {}),
3253
+ ...(judgeEffort ? { judgeEffort } : {}),
3004
3254
  output: step.output,
3005
3255
  schema: resolvedSchema ?? undefined,
3006
3256
  maxTurns: step.maxTurns,
3257
+ capture,
3007
3258
  ...limitOpts,
3008
3259
  ...hookOpts,
3009
3260
  stopRequested,
@@ -3203,7 +3454,7 @@ export function copyTreeBytes(from: string, to: string) {
3203
3454
  function openApiSources(agentDir: string, tenant: string): string[] {
3204
3455
  const out = new Set<string>();
3205
3456
  try {
3206
- const { data } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3457
+ const { data } = configMatter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
3207
3458
  const front = data as { apis?: unknown; tools?: unknown };
3208
3459
  for (const api of parseApis(front.apis)) if (api.openapi?.startsWith("https://")) out.add(api.openapi);
3209
3460
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
@@ -3266,7 +3517,7 @@ const CREDENTIAL_POLL_MS = 2_000;
3266
3517
  * step started one second before the 09:07:52Z refresh) matched none of the
3267
3518
  * older two, so the step failed instead of waiting for the new token. */
3268
3519
  export function isAuthRefusal(text: string): boolean {
3269
- return /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i.test(text);
3520
+ return AUTH_REFUSAL.test(text);
3270
3521
  }
3271
3522
 
3272
3523
  /**
@@ -3828,10 +4079,30 @@ function driveRunInner(
3828
4079
  *
3829
4080
  * The flag is one-way: nothing here clears it, so the read-modify-write can
3830
4081
  * only ever turn a stop on, never off.
4082
+ *
4083
+ * And once it is on, this copy may not undo what stopRun wrote: the run
4084
+ * stays failed, and steps that had not started stay skipped. Before
4085
+ * 21 Oct 2026 only the flag came across, so the next save put the record
4086
+ * back to `running` with its steps pending; a second stop then answered
4087
+ * "failed" instead of "already failed", and the group kept starting pods
4088
+ * (medium-desk run-mvho48wp-99e1). A step running in THIS process is left
4089
+ * as it is: its sandbox is gone, it ends on its own, and records how.
3831
4090
  */
3832
4091
  const save = () => {
3833
- if (!run.stopRequested && readRun(tenant, workspace, run.id)?.stopRequested) {
3834
- run.stopRequested = true;
4092
+ const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
4093
+ if (disk?.stopRequested) run.stopRequested = true;
4094
+ if (run.stopRequested) {
4095
+ if (run.status !== "completed" && run.status !== "failed") {
4096
+ run.status = "failed";
4097
+ run.finishedAt ??= disk?.finishedAt ?? new Date().toISOString();
4098
+ }
4099
+ run.parkedAt = null;
4100
+ for (const s of run.steps) {
4101
+ if (s.status === "pending" || s.status === "awaiting-approval") {
4102
+ s.status = "skipped";
4103
+ s.skipReason ??= "run stopped";
4104
+ }
4105
+ }
3835
4106
  }
3836
4107
  writeRun(tenant, workspace, run);
3837
4108
  };
@@ -3890,6 +4161,12 @@ function driveRunInner(
3890
4161
  try {
3891
4162
  // Entering the loop is what makes a run live, whatever state it was
3892
4163
  // saved in: `queued` from the queue, `awaiting-approval` from a park.
4164
+ // The first pick-up (FOL-54 step 4): startedAt to this is the time it
4165
+ // queued; and the files it runs are the workspace's latest revision
4166
+ // as it starts — a deploy's revision is its commit, and an edit through
4167
+ // /source makes one too. A re-run comes through here as well.
4168
+ run.drivenAt ??= new Date().toISOString();
4169
+ if (run.revision === undefined) run.revision = latestRevisionId(tenant, workspace);
3893
4170
  if (run.status !== "running") {
3894
4171
  run.status = "running";
3895
4172
  run.parkedAt = null;
@@ -4043,13 +4320,20 @@ function driveRunInner(
4043
4320
  for (let i = gi - 1; i >= 0; i--) if (groupData[i] !== undefined) return groupData[i];
4044
4321
  return undefined;
4045
4322
  };
4046
- const dataOf = (group: StepRecord[]): unknown => {
4323
+ // A rescue's result already stands on the step it took over, so the
4324
+ // three handoffs below read the flow's steps only: counting the rescue
4325
+ // too handed the next group the same output twice, or a stale one
4326
+ // from an evaluator loop's earlier cycle.
4327
+ const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !isRescueRecord(s));
4328
+ const dataOf = (all: StepRecord[]): unknown => {
4329
+ const group = flowStepsOf(all);
4047
4330
  const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
4048
4331
  if (carrying.length === 0) return undefined;
4049
4332
  if (carrying.length === 1 && !carrying[0].item) return carrying[0].data;
4050
4333
  return carrying.map((s) => ({ agent: s.agent, ...(s.item ? { item: s.item } : {}), data: s.data }));
4051
4334
  };
4052
- const resultsOf = (group: StepRecord[]): string | null => {
4335
+ const resultsOf = (all: StepRecord[]): string | null => {
4336
+ const group = flowStepsOf(all);
4053
4337
  const results = group
4054
4338
  .filter((s) => s.status === "completed" && s.result)
4055
4339
  .map((s) =>
@@ -4063,8 +4347,8 @@ function driveRunInner(
4063
4347
  };
4064
4348
  // What each step concluded — its final turn — for the handoffs that
4065
4349
  // should read a conclusion rather than a transcript (`each: lines`).
4066
- const conclusionsOf = (group: StepRecord[]): string | null => {
4067
- const out = group
4350
+ const conclusionsOf = (all: StepRecord[]): string | null => {
4351
+ const out = flowStepsOf(all)
4068
4352
  .filter((s) => s.status === "completed" && (s.conclusion || s.result))
4069
4353
  .map((s) => s.conclusion || s.result!);
4070
4354
  return out.length ? out.join("\n") : null;
@@ -4451,7 +4735,7 @@ function driveRunInner(
4451
4735
  save();
4452
4736
  if (freshGroup.every((s) => s.status !== "pending")) {
4453
4737
  // everything in this group was rejected
4454
- if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom)) {
4738
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
4455
4739
  run.status = "failed";
4456
4740
  break;
4457
4741
  }
@@ -4465,7 +4749,18 @@ function driveRunInner(
4465
4749
  // itself mid-turn at its share, so a fan-out of twenty cannot end
4466
4750
  // twenty steps over — the shares sum to what was left. Between
4467
4751
  // groups the check above still refuses to start the next one.
4468
- const launching = freshGroup.filter((s) => s.status === "pending");
4752
+ // A rescue record is the on-fail of a step in this group, not a step
4753
+ // of the flow: one left pending by a drive that died is picked up by
4754
+ // rescueStep below, never launched alongside the group (21 Oct 2026:
4755
+ // a resumed run launched 77 leftover fixers at once).
4756
+ const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
4757
+ // tools: [peers] — what each step of the group is told about the
4758
+ // others, and the bus they share when they run in this process.
4759
+ const peerBus = localPeerBus();
4760
+ const siblingsOf = (step: StepRecord): PeerInfo[] =>
4761
+ launching
4762
+ .filter((s) => s !== step)
4763
+ .map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
4469
4764
  // An agent's own `budget:` — the most it may spend in one run — is
4470
4765
  // read at launch, so a cap raised mid-run applies to the next step.
4471
4766
  const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
@@ -4502,15 +4797,48 @@ function driveRunInner(
4502
4797
  if (!pool) pools.set(key, (pool = slotPool(step.parallel)));
4503
4798
  return pool;
4504
4799
  };
4800
+ // One instance of each agent goes first so its siblings read the
4801
+ // prompt cache it wrote — see warmGate. The claim is the callback's
4802
+ // first statement, before any await; the wait comes before the
4803
+ // pool, so nothing holds a slot while it waits.
4804
+ const gate = warmGate(launching);
4505
4805
  await Promise.all(
4506
4806
  launching
4507
4807
  .map(async (step) => {
4808
+ const lead = gate.claim(step);
4809
+ if (lead) {
4810
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `prompt cache: waiting for the first ${step.agent} step's reply before starting, so this one reads its prefix` });
4811
+ save();
4812
+ await lead;
4813
+ }
4508
4814
  const pool = poolFor(step);
4509
4815
  if (pool) await pool.acquire();
4510
4816
  try {
4511
- await launchStep(step);
4817
+ // A stop that arrived while this copy waited — for the
4818
+ // lead's reply or for a slot — must not start it: the
4819
+ // between-groups check runs only once this group is over,
4820
+ // and save() is what carries the flag in from the record.
4821
+ {
4822
+ save();
4823
+ if (run.stopRequested) {
4824
+ step.status = "skipped";
4825
+ step.skipReason = "run stopped";
4826
+ step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
4827
+ save();
4828
+ gate.release(step);
4829
+ return;
4830
+ }
4831
+ }
4832
+ await gate.run(step, () => launchStep(step));
4512
4833
  } finally {
4513
4834
  pool?.release();
4835
+ // Every attempt is over: off its siblings' rosters, so a
4836
+ // message to it is not reported delivered and never read.
4837
+ // Here, not per attempt — a step between retries is still
4838
+ // one its colleagues can reach.
4839
+ const index = run.steps.indexOf(step);
4840
+ peerBus.leave(index);
4841
+ await platform.egress.stepEnded?.({ tenant, runId: run.id, step: index }).catch(() => undefined);
4514
4842
  }
4515
4843
  }),
4516
4844
  );
@@ -4574,6 +4902,8 @@ function driveRunInner(
4574
4902
  },
4575
4903
  flowTimezone,
4576
4904
  run.steps.indexOf(step),
4905
+ siblingsOf(step),
4906
+ peerBus,
4577
4907
  ).finally(() => reserved.delete(step));
4578
4908
  // runStep mutates step.status; read it through a widened local
4579
4909
  // so TS doesn't keep the "running" narrowing from above.
@@ -4668,6 +4998,13 @@ function driveRunInner(
4668
4998
  // runs out of cycles below, which used to fail the run without ever
4669
4999
  // reaching the step's on-fail (medium-desk's claim-trimmer was
4670
5000
  // written for exactly that case and could never run).
5001
+ // A rescue hears a stop the way a group step does: its model loop and
5002
+ // its executor's wait for room both poll this (no sandbox exists yet
5003
+ // for a stop to destroy while it waits).
5004
+ const stopPoll = () => {
5005
+ save();
5006
+ return run.stopRequested === true;
5007
+ };
4671
5008
  const rescueStep = async (step: StepRecord): Promise<void> => {
4672
5009
  if (!step.onFail) return;
4673
5010
  const rescuerDir = path.join(pDir, "agents", step.onFail);
@@ -4679,6 +5016,38 @@ function driveRunInner(
4679
5016
  });
4680
5017
  return;
4681
5018
  }
5019
+ // One rescue per failure, across drives. A worker that died
5020
+ // mid-rescue left an unfinished record behind it; the drive that
5021
+ // resumes picks that one up instead of splicing another — a crash
5022
+ // loop used to add one per restart (blog-desk run-mvh1zra7-ae4k,
5023
+ // ~80). The leftover carries the same instruction, built from the
5024
+ // same failure, and a running one may still hold a sandbox to
5025
+ // re-attach to, so it is THE rescue; any further unfinished copies
5026
+ // are superseded. A FINISHED rescue is history, never reused: it
5027
+ // answered an earlier failure — an evaluator loop's previous cycle
5028
+ // — not this one.
5029
+ const leftovers: StepRecord[] = [];
5030
+ for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
5031
+ const r = run.steps[j];
5032
+ if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
5033
+ leftovers.push(r);
5034
+ }
5035
+ }
5036
+ const resumed = leftovers[0];
5037
+ for (const r of leftovers.slice(1)) {
5038
+ r.status = "skipped";
5039
+ r.skipReason = "superseded on resume — one rescue per failure";
5040
+ }
5041
+ if (resumed) {
5042
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `resuming the rescue by [[${step.onFail}]] that an earlier drive started` });
5043
+ resumed.rescueOf ??= step.agent;
5044
+ resumed.attempts = Math.max(1, resumed.attempts ?? 0);
5045
+ resumed.status = "running";
5046
+ save();
5047
+ await runStep(rescuerDir, tenant, resumed, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(resumed));
5048
+ settleRescue(step, resumed);
5049
+ return;
5050
+ }
4682
5051
  step.events.push({
4683
5052
  t: new Date().toISOString(),
4684
5053
  type: "info",
@@ -4695,6 +5064,7 @@ function driveRunInner(
4695
5064
  const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
4696
5065
  const rescue: StepRecord = {
4697
5066
  agent: step.onFail,
5067
+ rescueOf: step.agent,
4698
5068
  instruction:
4699
5069
  `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
4700
5070
  `Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
@@ -4725,7 +5095,11 @@ function driveRunInner(
4725
5095
  rescue.attempts = 1;
4726
5096
  rescue.status = "running";
4727
5097
  save();
4728
- await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, undefined, flowTimezone, run.steps.indexOf(rescue));
5098
+ await runStep(rescuerDir, tenant, rescue, ctx, save, modelOverride, tags, effortOverride, run.id, ctxData, null, undefined, run.test === true, stopPoll, flowTimezone, run.steps.indexOf(rescue));
5099
+ settleRescue(step, rescue);
5100
+ };
5101
+ // What a rescue's end means for the step it took over.
5102
+ const settleRescue = (step: StepRecord, rescue: StepRecord): void => {
4729
5103
  // runStep mutates rescue.status; widen past TS's "running" narrowing.
4730
5104
  const rescueOutcome: string = rescue.status;
4731
5105
  if (rescueOutcome === "completed") {
@@ -4757,7 +5131,10 @@ function driveRunInner(
4757
5131
  // here: the person chose to start after it. Counting it stopped
4758
5132
  // fix-desk's "re-run from the verifier" (run-muwfi5jn-4217) in under
4759
5133
  // a second, before the one step it was asked to run.
4760
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom);
5134
+ // A rescue record's own failure is already on the step it took over
5135
+ // ("also failed"); counted here it would fail a group whose step a
5136
+ // later rescue completed.
5137
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
4761
5138
  if (requiredFailed) {
4762
5139
  run.status = "failed";
4763
5140
  break;
@@ -4833,6 +5210,10 @@ function driveRunInner(
4833
5210
  });
4834
5211
  for (const s of [...ordered[gi - 1], ...freshGroup]) {
4835
5212
  if (s.status === "skipped" && s.each) continue; // an expanded template stays expanded
5213
+ // A rescue answered its own failure and stays as it ended: reset
5214
+ // to pending it read as an interrupted rescue, and the next
5215
+ // cycle's failure was handed the old one's instructions.
5216
+ if (isRescueRecord(s)) continue;
4836
5217
  s.status = "pending";
4837
5218
  s.result = null;
4838
5219
  }
@@ -5073,7 +5454,14 @@ export interface Reconciliation {
5073
5454
  * outlives any process. A run whose person already answered is not waiting,
5074
5455
  * and is picked back up — see below.
5075
5456
  */
5076
- export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlySet<string>): Reconciliation[] {
5457
+ /** Told when a run reconcile closed, or resumed and saw to its end — so the
5458
+ * platform settles, notifies and chains it as it would a run its worker
5459
+ * drove (a run closed here had spent tokens, and nobody was charged or
5460
+ * told). Not told when a resumed run parks again: whatever resumes it next
5461
+ * goes through the worker. */
5462
+ export type ReconcileEnd = (tenant: string, workspace: string, runId: string) => void | Promise<void>;
5463
+
5464
+ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[] {
5077
5465
  const closed: Reconciliation[] = [];
5078
5466
 
5079
5467
  for (const workspace of listWorkspaces(tenant)) {
@@ -5108,7 +5496,12 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
5108
5496
  // Park at any *later* gate rather than poll: reconcile runs inside
5109
5497
  // long-lived server processes, where a 24h file-poll is exactly the
5110
5498
  // slot-burning the queue exists to avoid.
5111
- void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true });
5499
+ void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
5500
+ .then(() => {
5501
+ const after = readRun(tenant, workspace.name, run.id);
5502
+ if (onEnd && after && (after.status === "completed" || after.status === "failed")) return onEnd(tenant, workspace.name, run.id);
5503
+ })
5504
+ .catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after resume:`, err));
5112
5505
  closed.push({
5113
5506
  runId: run.id,
5114
5507
  tenant,
@@ -5153,6 +5546,11 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
5153
5546
  // a sandbox we cannot reach must not stop the reconcile
5154
5547
  }
5155
5548
  closed.push({ runId: run.id, tenant, workspace: workspace.name, action: "closed", interrupted });
5549
+ if (onEnd) {
5550
+ void Promise.resolve()
5551
+ .then(() => onEnd(tenant, workspace.name, run.id))
5552
+ .catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after close:`, err));
5553
+ }
5156
5554
  }
5157
5555
  }
5158
5556
 
@@ -5166,8 +5564,8 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
5166
5564
  * one hard-coded account left every other account's interrupted runs marked
5167
5565
  * `running` forever, with no process left to finish them.
5168
5566
  */
5169
- export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string>): Reconciliation[] {
5170
- return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
5567
+ export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[] {
5568
+ return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live, onEnd));
5171
5569
  }
5172
5570
 
5173
5571
  /**