@foldrun/core 2.3.0 → 2.5.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 (135) 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 +1 -1
  4. package/dist/src/agent-tools.js +1 -1
  5. package/dist/src/agents-md.js +1 -1
  6. package/dist/src/alerts.d.ts +60 -0
  7. package/dist/src/alerts.js +161 -0
  8. package/dist/src/browser-pod.js +4 -0
  9. package/dist/src/capture.d.ts +23 -0
  10. package/dist/src/capture.js +80 -0
  11. package/dist/src/compare.d.ts +57 -0
  12. package/dist/src/compare.js +116 -0
  13. package/dist/src/completions.js +1 -0
  14. package/dist/src/deploy.d.ts +36 -1
  15. package/dist/src/deploy.js +170 -11
  16. package/dist/src/egress.d.ts +16 -1
  17. package/dist/src/evals.d.ts +5 -1
  18. package/dist/src/evals.js +26 -18
  19. package/dist/src/frontmatter-fields.js +9 -0
  20. package/dist/src/frontmatter-schema.js +3 -0
  21. package/dist/src/history.d.ts +4 -0
  22. package/dist/src/history.js +15 -0
  23. package/dist/src/hooks.js +1 -1
  24. package/dist/src/insights.d.ts +30 -0
  25. package/dist/src/insights.js +152 -0
  26. package/dist/src/issues.d.ts +140 -0
  27. package/dist/src/issues.js +302 -0
  28. package/dist/src/library.js +1 -1
  29. package/dist/src/limits.d.ts +8 -0
  30. package/dist/src/limits.js +36 -5
  31. package/dist/src/linkable.js +1 -1
  32. package/dist/src/matter.d.ts +8 -0
  33. package/dist/src/matter.js +28 -0
  34. package/dist/src/model-credential.d.ts +14 -0
  35. package/dist/src/model-credential.js +40 -0
  36. package/dist/src/model-env.d.ts +14 -2
  37. package/dist/src/model-env.js +34 -9
  38. package/dist/src/network.d.ts +12 -0
  39. package/dist/src/network.js +25 -1
  40. package/dist/src/notify.d.ts +16 -0
  41. package/dist/src/notify.js +17 -7
  42. package/dist/src/okf.js +1 -1
  43. package/dist/src/online-checks.d.ts +76 -0
  44. package/dist/src/online-checks.js +432 -0
  45. package/dist/src/operator.d.ts +277 -0
  46. package/dist/src/operator.js +176 -2
  47. package/dist/src/otel-config.d.ts +18 -0
  48. package/dist/src/otel-config.js +69 -0
  49. package/dist/src/otel.d.ts +38 -0
  50. package/dist/src/otel.js +85 -0
  51. package/dist/src/outcome.d.ts +40 -0
  52. package/dist/src/outcome.js +77 -0
  53. package/dist/src/package.js +1 -1
  54. package/dist/src/platform.d.ts +1 -1
  55. package/dist/src/prompt-layers.d.ts +30 -0
  56. package/dist/src/prompt-layers.js +77 -0
  57. package/dist/src/provider-health.js +1 -1
  58. package/dist/src/providers.d.ts +5 -0
  59. package/dist/src/providers.js +2 -2
  60. package/dist/src/run-container.d.ts +16 -2
  61. package/dist/src/run-container.js +57 -5
  62. package/dist/src/runner.d.ts +53 -2
  63. package/dist/src/runner.js +356 -70
  64. package/dist/src/runs-filter.d.ts +48 -0
  65. package/dist/src/runs-filter.js +73 -0
  66. package/dist/src/scan.d.ts +19 -1
  67. package/dist/src/scan.js +60 -7
  68. package/dist/src/starter.js +4 -2
  69. package/dist/src/step-exec.d.ts +29 -1
  70. package/dist/src/step-exec.js +106 -6
  71. package/dist/src/store.d.ts +130 -3
  72. package/dist/src/store.js +145 -6
  73. package/dist/src/tokens.d.ts +38 -0
  74. package/dist/src/tokens.js +57 -0
  75. package/dist/src/tool-names.js +1 -1
  76. package/dist/src/tool-programs.js +1 -1
  77. package/dist/src/trace-tree.d.ts +40 -0
  78. package/dist/src/trace-tree.js +156 -0
  79. package/dist/src/translator.d.ts +8 -0
  80. package/dist/src/translator.js +7 -2
  81. package/dist/src/trigger-log.d.ts +1 -1
  82. package/dist/src/workspace-check.d.ts +18 -0
  83. package/dist/src/workspace-check.js +34 -0
  84. package/package.json +2 -2
  85. package/src/agent-activity.ts +32 -0
  86. package/src/agent-import.ts +1 -1
  87. package/src/agent-tools.ts +1 -1
  88. package/src/agents-md.ts +1 -1
  89. package/src/alerts.ts +190 -0
  90. package/src/browser-pod.ts +3 -0
  91. package/src/capture.ts +88 -0
  92. package/src/compare.ts +146 -0
  93. package/src/completions.ts +1 -0
  94. package/src/deploy.ts +184 -10
  95. package/src/egress.ts +12 -2
  96. package/src/evals.ts +26 -18
  97. package/src/frontmatter-fields.ts +9 -0
  98. package/src/frontmatter-schema.ts +3 -0
  99. package/src/history.ts +13 -0
  100. package/src/hooks.ts +1 -1
  101. package/src/insights.ts +174 -0
  102. package/src/issues.ts +396 -0
  103. package/src/library.ts +1 -1
  104. package/src/limits.ts +38 -5
  105. package/src/linkable.ts +1 -1
  106. package/src/matter.ts +33 -0
  107. package/src/model-credential.ts +45 -0
  108. package/src/model-env.ts +42 -10
  109. package/src/network.ts +25 -1
  110. package/src/notify.ts +22 -7
  111. package/src/okf.ts +1 -1
  112. package/src/online-checks.ts +450 -0
  113. package/src/operator.ts +213 -3
  114. package/src/otel-config.ts +67 -0
  115. package/src/otel.ts +100 -0
  116. package/src/outcome.ts +90 -0
  117. package/src/package.ts +1 -1
  118. package/src/platform.ts +1 -1
  119. package/src/prompt-layers.ts +90 -0
  120. package/src/provider-health.ts +1 -1
  121. package/src/providers.ts +7 -2
  122. package/src/run-container.ts +66 -7
  123. package/src/runner.ts +363 -46
  124. package/src/runs-filter.ts +88 -0
  125. package/src/scan.ts +68 -7
  126. package/src/starter.ts +4 -2
  127. package/src/step-exec.ts +147 -9
  128. package/src/store.ts +254 -8
  129. package/src/tokens.ts +68 -0
  130. package/src/tool-names.ts +1 -1
  131. package/src/tool-programs.ts +1 -1
  132. package/src/trace-tree.ts +187 -0
  133. package/src/translator.ts +13 -2
  134. package/src/trigger-log.ts +1 -0
  135. package/src/workspace-check.ts +41 -0
@@ -2,11 +2,14 @@
2
2
  // group receives every prior result, labeled per agent. Optional steps ("2?")
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
+ import { latestRevisionId } from "./history.js";
6
+ import { recordCallIo, redactionsFor } from "./capture.js";
7
+ import { AUTH_REFUSAL } from "./issues.js";
5
8
  import { hookNames } from "./hooks.js";
6
9
  import fs from "node:fs";
7
10
  import os from "node:os";
8
11
  import path from "node:path";
9
- import matter from "gray-matter";
12
+ import matter from "./matter.js";
10
13
  import { knownPrice, executeStep, extractJson, stepCeiling, stepCeilingFor } from "./step-exec.js";
11
14
  import { cascadeLimits, toolOwners } from "./limits.js";
12
15
  import { resolveAgentPath } from "./confine.js";
@@ -14,7 +17,7 @@ import { browserPodLine, podLossDecision, RERAN_ON_FULL } from "./browser-pod.js
14
17
  import { eventUrl } from "./webhook.js";
15
18
  import { runStepInContainer, sizeLimits, killRunSandboxes } from "./run-container.js";
16
19
  import { hostSafeEnv, platformApiUrl } from "./host-env.js";
17
- import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
20
+ import { resolveModelCredential, credentialLine, accountModelBlock, isClaudeLoginToken, isModelEnvName, DECLARED_ENV_PREFIX } from "./model-credential.js";
18
21
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
19
22
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute } from "./egress.js";
20
23
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote } from "./refusal.js";
@@ -29,15 +32,16 @@ const isolatedRun = () => {
29
32
  };
30
33
  import { gatherConsults, buildConsultTools } from "./agent-tools.js";
31
34
  import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools } from "./subagents.js";
32
- import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.js";
35
+ import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
33
36
  import { sendPlainNotification } from "./notify.js";
34
37
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
35
38
  import { refNames } from "./refs.js";
36
39
  import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
37
40
  import { canReadBack } from "./tool-output.js";
41
+ import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
38
42
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
39
43
  import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
40
- import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
44
+ import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.js";
41
45
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
42
46
  import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
43
47
  import { attachOperations, prefetchOpenApi } from "./openapi.js";
@@ -155,6 +159,105 @@ export function slotPool(size) {
155
159
  },
156
160
  };
157
161
  }
162
+ /** Steps whose model has begun its first response — the step's executor
163
+ * says so the moment the stream starts (EventExtra.responding). Memory
164
+ * only: it is scheduling, not something the run record needs. */
165
+ const respondingSteps = new WeakSet();
166
+ export function markResponding(step) {
167
+ respondingSteps.add(step);
168
+ }
169
+ /**
170
+ * Is this step's prompt in the cache yet? An entry is readable from the
171
+ * moment the response to the request that wrote it begins streaming
172
+ * (Anthropic's prompt-caching docs), which is what `responding` marks.
173
+ * The first text, tool call or recorded model call says the same a little
174
+ * later, for an executor that cannot see the stream (the stub, an older
175
+ * driver). Resolves on the first of those, or when the step is over
176
+ * without one (a refusal before the first turn, a failed start).
177
+ */
178
+ export function untilFirstReply(step, done, pollMs = 250) {
179
+ const replied = () => respondingSteps.has(step) || step.events.some((e) => e.type === "text" || e.type === "tool") || (step.calls?.length ?? 0) > 0;
180
+ return new Promise((resolve) => {
181
+ let finished = false;
182
+ const stop = () => {
183
+ finished = true;
184
+ resolve();
185
+ };
186
+ done.then(stop, stop);
187
+ const tick = () => {
188
+ if (finished)
189
+ return;
190
+ if (replied())
191
+ return stop();
192
+ setTimeout(tick, pollMs).unref?.();
193
+ };
194
+ tick();
195
+ });
196
+ }
197
+ /**
198
+ * Steps of one agent launched together share a prompt prefix — tools and
199
+ * system prompt — and that is what the model's cache keeps. The cache is
200
+ * written by the first request that carries a prefix and readable only
201
+ * once that response has begun, so N copies started at once all write it
202
+ * and none reads: a fan-out of twenty paid twenty cold prefixes. The gate
203
+ * lets the first instance of each agent go alone and holds its siblings
204
+ * until its first response has begun (or it ended); then they all start
205
+ * and read what it wrote — Claude Code holds a workflow's fan-out the same
206
+ * way. Not a fixed delay: a sandbox takes seconds to start before its
207
+ * first model call, so a timer from launch would release the siblings
208
+ * before there was anything to read. Keyed by agent and model, since a
209
+ * cache is per model.
210
+ */
211
+ export function warmGate(launching, pollMs = 250) {
212
+ const key = (s) => `${s.agent}\u0000${s.model ?? ""}`;
213
+ const counts = new Map();
214
+ for (const s of launching)
215
+ counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
216
+ const leads = new Map();
217
+ const isLead = new WeakSet();
218
+ return {
219
+ /** Claim the step's place — synchronously, before anything awaits, or
220
+ * the copies that reach their first await together would all find no
221
+ * lead and all go (a `parallel:` pool's acquire was exactly that).
222
+ * The first of its agent leads and gets null; a later one gets the
223
+ * lead's gate to await before it starts; a lone step gets null too. */
224
+ claim(step) {
225
+ const k = key(step);
226
+ if ((counts.get(k) ?? 0) < 2)
227
+ return null;
228
+ const lead = leads.get(k);
229
+ if (lead)
230
+ return lead.opened;
231
+ let open;
232
+ const opened = new Promise((r) => (open = r));
233
+ leads.set(k, { opened, open });
234
+ isLead.add(step);
235
+ return null;
236
+ },
237
+ /** A lead that will not run after all (the run was stopped while it
238
+ * waited for a pool slot) still opens its gate — or its siblings,
239
+ * which also wake to the stop, would wait on it forever. */
240
+ release(step) {
241
+ if (isLead.has(step))
242
+ leads.get(key(step)).open();
243
+ },
244
+ /** Run the step; a lead opens its agent's gate on its first reply (or
245
+ * its end), whatever else happens to it. */
246
+ async run(step, launch) {
247
+ if (!isLead.has(step))
248
+ return launch();
249
+ const { open } = leads.get(key(step));
250
+ const done = launch();
251
+ try {
252
+ await untilFirstReply(step, done, pollMs);
253
+ }
254
+ finally {
255
+ open();
256
+ }
257
+ await done;
258
+ },
259
+ };
260
+ }
158
261
  const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" && v.trim() ? [v.trim()] : [];
159
262
  // gray-matter throws on invalid YAML. Skills authored for another client
160
263
  // sometimes ship YAML that that client's parser happened to accept — most
@@ -162,12 +265,9 @@ const toTags = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" &
162
265
  // The Agent Skills client guide recommends quoting such values and retrying
163
266
  // rather than dropping the skill, so a cross-client skill still loads here.
164
267
  function safeMatter(raw) {
165
- // gray-matter caches by input string AND caches a *failed* parse: the first
166
- // call on invalid YAML throws, but every later call on the same bytes returns
167
- // a hollow object with the fields silently missing. Clearing the cache makes
168
- // the throw deterministic, so the fallback below fires every time rather than
169
- // once — otherwise the same skill loads on one run and vanishes on the next.
170
- matter.clearCache();
268
+ // gray-matter caches a *failed* parse as a hollow success; ./matter.ts
269
+ // parses every call, so the throw below is deterministic and the fallback
270
+ // fires every time (it used to clear gray-matter's cache here for that).
171
271
  try {
172
272
  return matter(raw);
173
273
  }
@@ -351,6 +451,9 @@ export function workspaceRuntimePlan(tenant, workspace) {
351
451
  }
352
452
  return [...byFp.values()];
353
453
  }
454
+ /** The most tool content one step records (FOL-54 step 4): about 60 calls
455
+ * at the per-part cap; past it, metadata only. */
456
+ const STEP_CAPTURE_LIMIT = 4_000_000;
354
457
  function workspaceFrontmatter(agentDir, tenant) {
355
458
  return {
356
459
  ...(readAgentsMd(accountDir(tenant))?.data ?? {}),
@@ -651,7 +754,11 @@ grantsOnly = false) {
651
754
  // workspace a secret should resolve against is two levels up.
652
755
  const workspace = path.basename(path.resolve(agentDir, "..", ".."));
653
756
  const { data: front, content: body } = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"));
654
- const parts = [resolveDocLinks(body.trim(), path.resolve(agentDir, "..", ".."))];
757
+ // The prompt's sections, by name and by how often they change. Added in
758
+ // the order they are computed; assembled stable first, so the model's
759
+ // cache keeps the agent's half across steps and runs (prompt-layers.ts).
760
+ const parts = new PromptLayers();
761
+ parts.add("persona", resolveDocLinks(body.trim(), path.resolve(agentDir, "..", "..")));
655
762
  // Where the agent is standing. Every path below this line is relative to the
656
763
  // agent's own directory, and a run showed why that has to be said rather
657
764
  // than implied: told to read `../../knowledge/index.md`, the model asked for
@@ -672,12 +779,12 @@ grantsOnly = false) {
672
779
  const languageSet = language.from !== "default" && language.from !== "env";
673
780
  const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
674
781
  if (languageSet || regionSet) {
675
- parts.push(`# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
782
+ parts.add("locale", `# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
676
783
  `Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
677
784
  `use these currency, units and calendar conventions in anything you produce. ` +
678
785
  `Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`);
679
786
  }
680
- parts.push(`# Where you are\n\n` +
787
+ parts.add("where", `# Where you are\n\n` +
681
788
  `Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
682
789
  `\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
683
790
  `\`outputs/\` is yours, and \`workspace/\` is the workspace root, in every tool ` +
@@ -720,7 +827,7 @@ grantsOnly = false) {
720
827
  // background the rest of the prompt is read against, not an afterthought.
721
828
  const sharedContext = sharedInstructions(agentDir, tenant);
722
829
  if (sharedContext)
723
- parts.push(sharedContext);
830
+ parts.add("agents.md", sharedContext);
724
831
  // Skills follow the open Agent Skills format (agentskills.io): a folder
725
832
  // with SKILL.md, optionally bundling its own scripts/ and references/. The
726
833
  // flat `skills/<name>.md` form is also accepted for small skills.
@@ -754,7 +861,7 @@ grantsOnly = false) {
754
861
  `Full instructions: \`${s.path}\`` +
755
862
  (bundles.length ? ` (also bundles ${bundles.join(", ")})` : ""));
756
863
  });
757
- parts.push(`# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
864
+ parts.add("skills", `# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
758
865
  `read its file for the full instructions before proceeding:\n\n${lines.join("\n")}` +
759
866
  (withheld ? `\n\n(${withheld} further skills exist but do not apply to this run.)` : ""));
760
867
  }
@@ -773,10 +880,11 @@ grantsOnly = false) {
773
880
  : "";
774
881
  const projectMem = workspaceMemoryIndex(tenant, workspace);
775
882
  const shared = libraryMemoryIndex(tenant);
776
- parts.push(`# Memory — what you have learned\n\n${memoryIndex ? `Your own (files under memory/):\n\n${memoryIndex}` : "You have no memories of your own yet."}` +
883
+ // Dynamic: agents write memory, so the index moves between runs.
884
+ parts.add("memory", `# Memory — what you have learned\n\n${memoryIndex ? `Your own (files under memory/):\n\n${memoryIndex}` : "You have no memories of your own yet."}` +
777
885
  (projectMem ? `\n\nShared across this workspace:\n\n${projectMem}` : "") +
778
886
  (shared ? `\n\nShared across every workspace:\n\n${shared}` : "") +
779
- howTo);
887
+ howTo, { dynamic: true });
780
888
  }
781
889
  // Knowledge — reference material the agent was given. Same three scopes and
782
890
  // the same nearest-wins reading order as memory, but never written back:
@@ -791,7 +899,7 @@ grantsOnly = false) {
791
899
  projectKnowledge && `Shared across this workspace:\n\n${projectKnowledge}`,
792
900
  sharedKnowledge && `Shared across every workspace:\n\n${sharedKnowledge}`,
793
901
  ].filter(Boolean);
794
- parts.push(`# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
902
+ parts.add("knowledge", `# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
795
903
  `on it rather than guessing at its contents. This is read-only: it is maintained by ` +
796
904
  `people, not by you — if you learn something new, write it to memory/ instead.\n\n` +
797
905
  sections.join("\n\n"));
@@ -822,7 +930,7 @@ grantsOnly = false) {
822
930
  shared.length ? `Shared across this workspace:\n${shared.join("\n")}` : null,
823
931
  global.length ? `From the account library (read-only):\n${global.join("\n")}` : null,
824
932
  ].filter(Boolean);
825
- parts.push(`# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
933
+ parts.add("scripts", `# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
826
934
  `Read a script before running it if you are unsure what it does. Credentials your ` +
827
935
  `scripts need are already in the environment as the secrets listed below.`);
828
936
  }
@@ -837,7 +945,8 @@ grantsOnly = false) {
837
945
  // file to size it against, not before.
838
946
  const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
839
947
  if (state.length) {
840
- parts.push(`# State — what you carry between runs\n\n` +
948
+ // Dynamic: runs add and remove state files.
949
+ parts.add("state", `# State — what you carry between runs\n\n` +
841
950
  `What this workspace keeps across runs: a count, a cursor, where you got to. ` +
842
951
  `Read and write these directly, and update them before you finish so the next ` +
843
952
  `run starts where you left off.\n\n${state.join("\n")}\n\n` +
@@ -845,7 +954,7 @@ grantsOnly = false) {
845
954
  `A count reads as well in a sentence as in JSON, and a person edits these in the ` +
846
955
  `same editor as everything else — data formats are allowed here, not preferred.\n\n` +
847
956
  `This is not memory. A fact you learned goes in memory/, where it is indexed and ` +
848
- `its provenance recorded — state is the bookmark, not the lesson.`);
957
+ `its provenance recorded — state is the bookmark, not the lesson.`, { dynamic: true });
849
958
  }
850
959
  // APIs — the agent's own definitions, plus workspace tools it granted by
851
960
  // name. Library tools are defined once and reused.
@@ -928,7 +1037,7 @@ grantsOnly = false) {
928
1037
  const keepIn = { dir: agentDir, readable: () => readsFiles };
929
1038
  const apiTools = buildApiTools(tenant, apis, workspace, undefined, keepIn);
930
1039
  if (apiTools.promptLines.length) {
931
- parts.push(`# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
1040
+ parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
932
1041
  }
933
1042
  // Secrets an agent declared become env vars for its scripts and bash.
934
1043
  //
@@ -1079,6 +1188,10 @@ grantsOnly = false) {
1079
1188
  name: spec.name,
1080
1189
  maxTokensParam: preset?.maxTokensParam,
1081
1190
  reasoningEffort: preset?.reasoningEffort,
1191
+ // The agent's prompts share an opening; the key says so to an
1192
+ // endpoint that routes its cache by one. Only where the preset
1193
+ // says the parameter is understood — elsewhere it is a 400.
1194
+ cacheKey: preset?.promptCacheKey ? promptCacheKey(tenant, workspace, path.basename(agentDir)) : undefined,
1082
1195
  });
1083
1196
  // What the run is actually pointed at, in one line: the endpoint, the
1084
1197
  // tiers this gateway renames, and whether the credential landed.
@@ -1261,10 +1374,12 @@ grantsOnly = false) {
1261
1374
  FOLDRUN_WORKSPACE: workspace,
1262
1375
  ...clockEnv,
1263
1376
  }, libraryDir(tenant, "scripts"), runtime.interpreters, exec, keepIn.readable);
1264
- parts.push(`# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
1265
- `write or check — it is the workspace's calendar, not the container's clock.`);
1377
+ // Dynamic: the date moves daily, and would otherwise invalidate every
1378
+ // layer behind it once a day.
1379
+ parts.add("clock", `# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
1380
+ `write or check — it is the workspace's calendar, not the container's clock.`, { dynamic: true });
1266
1381
  if (scriptTools.promptLines.length) {
1267
- parts.push(`# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
1382
+ parts.add("script-tools", `# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
1268
1383
  `\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`);
1269
1384
  }
1270
1385
  // The web tool's own description lists every action, so an agent whose
@@ -1273,15 +1388,15 @@ grantsOnly = false) {
1273
1388
  // reddit-desk validator step on 6 Oct 2026. Say it before the first call.
1274
1389
  if (builtins.length) {
1275
1390
  const named = builtins.map((b) => b.split("="));
1276
- parts.push(`# Web search and fetch on this run\n\n` +
1391
+ parts.add("web", `# Web search and fetch on this run\n\n` +
1277
1392
  named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
1278
1393
  `\n\nThe web tool's other actions work as usual.`);
1279
1394
  }
1280
1395
  if (declared.length) {
1281
- parts.push(`# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
1396
+ parts.add("secrets", `# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
1282
1397
  `Never print, echo, or write them to a file.`);
1283
1398
  }
1284
- parts.push("Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
1399
+ parts.add("rules", "Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
1285
1400
  "what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
1286
1401
  "for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
1287
1402
  "directory a tool made outside the workspace, such as a checkout, is reached only through that tool. " +
@@ -1298,7 +1413,7 @@ grantsOnly = false) {
1298
1413
  // run, and an agent that opened with "I'll start by cloning the repo" made
1299
1414
  // all three useless. Asking here rather than in each agent.md means every
1300
1415
  // agent obeys it, including the ones written before the summary existed.
1301
- parts.push("Open your reply with one sentence saying what you concluded or produced — the outcome, " +
1416
+ parts.add("rules", "Open your reply with one sentence saying what you concluded or produced — the outcome, " +
1302
1417
  "not your plan. Everything else follows it. That sentence is what a person sees in the " +
1303
1418
  "run list and in the notification, without opening the run.");
1304
1419
  // One list. `tools:` grants anything: a built-in group, an exact SDK tool
@@ -1340,6 +1455,7 @@ grantsOnly = false) {
1340
1455
  let wantHistory = false;
1341
1456
  let wantDesks = false;
1342
1457
  let wantAsk = false;
1458
+ let wantPeers = false;
1343
1459
  for (const ref of toolRefs(front)) {
1344
1460
  const toolName = ref.name;
1345
1461
  if (ref.mode === "ask") {
@@ -1374,7 +1490,7 @@ grantsOnly = false) {
1374
1490
  if (available[toolName])
1375
1491
  shadowed.push(toolName);
1376
1492
  }
1377
- else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
1493
+ else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask" || toolName === "peers") {
1378
1494
  // The platform's own groups, served in-process — built below, once
1379
1495
  // the roots and the records are known.
1380
1496
  if (toolName === "search")
@@ -1383,6 +1499,8 @@ grantsOnly = false) {
1383
1499
  wantHistory = true;
1384
1500
  else if (toolName === "ask")
1385
1501
  wantAsk = true;
1502
+ else if (toolName === "peers")
1503
+ wantPeers = true;
1386
1504
  else
1387
1505
  wantDesks = true;
1388
1506
  if (available[toolName])
@@ -1444,10 +1562,17 @@ grantsOnly = false) {
1444
1562
  const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
1445
1563
  if (ask) {
1446
1564
  allowed.push(ASK_TOOL);
1447
- parts.push(`# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1565
+ parts.add("ask", `# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1448
1566
  }
1567
+ // tools: [peers] — peers_list and peers_message, to the other steps of
1568
+ // this step's parallel group. Who they are is known only when the group
1569
+ // launches, so the grant is here and the roster, the server and the
1570
+ // prompt's line are the step's (runStep).
1571
+ const peers = wantPeers;
1572
+ if (peers)
1573
+ allowed.push(...PEER_TOOLS);
1449
1574
  if (searchTools.promptLines.length || historyTools.promptLines.length || deskTools.promptLines.length) {
1450
- parts.push(`# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
1575
+ parts.add("finding", `# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
1451
1576
  }
1452
1577
  // An MCP server declares its tools when it connects, so they can't be listed
1453
1578
  // ahead of time. Server-level specs cover them: granting the server grants
@@ -1469,13 +1594,16 @@ grantsOnly = false) {
1469
1594
  denied.push(INSTALL_TOOL);
1470
1595
  const finalAllowed = allowed.filter((t) => !denied.includes(t));
1471
1596
  readsFiles = canReadBack(finalAllowed);
1597
+ const prompt = parts.assemble();
1472
1598
  return {
1473
1599
  front,
1474
1600
  clockEnv,
1475
1601
  /** The zone, where it came from, and anything unreadable on the way —
1476
1602
  * reported by the step, not here, so it lands in the run's trace. */
1477
1603
  clock,
1478
- systemPrompt: parts.join("\n\n"),
1604
+ systemPrompt: prompt.text,
1605
+ /** What the record keeps of each layer of that prompt. */
1606
+ promptLayers: prompt.layers,
1479
1607
  allowed: finalAllowed,
1480
1608
  disabled,
1481
1609
  unknownTools,
@@ -1499,6 +1627,7 @@ grantsOnly = false) {
1499
1627
  historyTools,
1500
1628
  deskTools,
1501
1629
  ask,
1630
+ peers,
1502
1631
  // `spec` is the MERGED declaration — the agent's own block or its
1503
1632
  // workspace's, plus every granted tool's. The isolated path builds its
1504
1633
  // runtime from this; passing the agent's frontmatter alone silently drops
@@ -1730,7 +1859,13 @@ flowTimezone,
1730
1859
  /** The step's index in run.steps, carried on its egress lease so a
1731
1860
  * person's message to this step reaches this step's own inbox and not a
1732
1861
  * sibling's running in the same group. Undefined outside a flow. */
1733
- stepIndex) {
1862
+ stepIndex,
1863
+ /** The other steps launched with this one (its parallel group), for
1864
+ * tools: [peers]. Empty when it runs alone. */
1865
+ siblings = [],
1866
+ /** The in-process colleagues' bus for this group (localPeerBus), when
1867
+ * the step runs in this process rather than a pod. */
1868
+ peerBus) {
1734
1869
  // Secret values are injected into scripts as environment variables and
1735
1870
  // substituted into API headers, so a model that reads one back — from a
1736
1871
  // script that prints it, an API error that echoes it, or its own `env` —
@@ -1776,9 +1911,56 @@ stepIndex) {
1776
1911
  return value;
1777
1912
  }
1778
1913
  };
1914
+ // What this workspace records of each step (FOL-54 step 4): `capture:` in
1915
+ // AGENTS.md — every model call always; each tool call's input and output
1916
+ // unless it says `metadata`.
1917
+ const capture = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
1918
+ // A step that pours out content stops being recorded past this — its
1919
+ // calls keep their names, sizes and times (FOL-54 step 4).
1920
+ let capturedChars = 0;
1921
+ let captureFull = false;
1779
1922
  const push = (type, text, extra) => {
1780
1923
  const t = new Date().toISOString();
1781
- step.events.push({ t, type, text: redact(text), ...extra });
1924
+ // The model has begun its first response: held only in memory, for
1925
+ // the fan-out gate (warmGate). Not a line in the trace.
1926
+ if (extra?.responding) {
1927
+ markResponding(step);
1928
+ return;
1929
+ }
1930
+ // A model call is a span on the step, not a line in its trace.
1931
+ // Numbered here, across attempts: a retry's calls follow the first
1932
+ // attempt's, and an id-less turn of one attempt never overwrites
1933
+ // another's. No save of its own — a turn's blocks arrive as several
1934
+ // messages, and the next event saves them all.
1935
+ if (extra?.turn) {
1936
+ const calls = (step.calls ??= []);
1937
+ // The loop counts attempts from 1; a call of the first carries none.
1938
+ const attempt = Math.max(1, step.attempts ?? 1);
1939
+ const at = calls.findIndex((c) => c.id === extra.turn.id && (c.attempt ?? 1) === attempt);
1940
+ const call = { ...extra.turn, n: at >= 0 ? calls[at].n : calls.length + 1, ...(attempt > 1 ? { attempt } : {}) };
1941
+ if (at >= 0)
1942
+ calls[at] = call;
1943
+ else
1944
+ calls.push(call);
1945
+ return;
1946
+ }
1947
+ // A tool call's content goes beside the run, masked (capture.ts), and the
1948
+ // event keeps only that it was recorded.
1949
+ let rest = extra;
1950
+ if (extra?.io) {
1951
+ const { io, ...others } = extra;
1952
+ rest = others;
1953
+ const size = (io.input?.length ?? 0) + (io.output?.length ?? 0);
1954
+ if (runId && extra.call && capture === "full" && capturedChars + size > STEP_CAPTURE_LIMIT && !captureFull) {
1955
+ captureFull = true;
1956
+ 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` });
1957
+ }
1958
+ if (runId && extra.call && capture === "full" && !captureFull && recordCallIo(path.resolve(agentDir, "..", ".."), runId, extra.call, text, io, redact)) {
1959
+ capturedChars += size;
1960
+ rest = { ...others, captured: true };
1961
+ }
1962
+ }
1963
+ step.events.push({ t, type, text: redact(text), ...rest });
1782
1964
  // A person in the loop: the question, its answer, a message delivered —
1783
1965
  // onto the step, so the run page, the API and history show them.
1784
1966
  if (extra?.operator) {
@@ -1810,7 +1992,27 @@ stepIndex) {
1810
1992
  step.status = "completed";
1811
1993
  // A stub answer may open with `cost: 0.25` — a pretend price, so the
1812
1994
  // parts of orchestration that watch money (budget:) are testable too.
1813
- const answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
1995
+ let answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
1996
+ // `call: <tool> <input> => <output>` lines: a pretend tool call, sent
1997
+ // through the same push a real step's events take — with its model call
1998
+ // and its content — so what the runner records of a step is testable.
1999
+ answer = answer
2000
+ .split("\n")
2001
+ .filter((line) => {
2002
+ const m = /^call: (\S+) (.*?) => (.*)$/.exec(line);
2003
+ if (!m)
2004
+ return true;
2005
+ const id = `toolu_stub${(step.calls?.length ?? 0) + 1}`;
2006
+ // Twice under one id, as the SDK sends a turn's blocks: one row.
2007
+ 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 };
2008
+ push("info", "", { turn });
2009
+ push("info", "", { turn: { ...turn, stop: "tool_use", tools: 1 } });
2010
+ push("tool", m[1], { call: id, ...(capture ? { io: { input: m[2], inputChars: m[2].length } } : {}) });
2011
+ push("tool", m[1], { call: id, ms: 3, io: { output: m[3], outputChars: m[3].length } });
2012
+ return false;
2013
+ })
2014
+ .join("\n")
2015
+ .trim();
1814
2016
  const priced = answer.match(/^cost:\s*([\d.]+)\n?/);
1815
2017
  const body = (priced ? answer.slice(priced[0].length) : answer).trim();
1816
2018
  // A `===` line inside an answer separates earlier turns from the final
@@ -1849,12 +2051,15 @@ stepIndex) {
1849
2051
  // step: the generic HTTP tool still works without the typed ones.
1850
2052
  for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
1851
2053
  push("error", `openapi: ${w}`);
1852
- const { front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
2054
+ const { front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, peers, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
1853
2055
  disposeRuntime = runtime.dispose;
1854
2056
  canActOutward = outward;
1855
2057
  // Sent again on every turn; with turns and the tools' result sizes, what
1856
2058
  // a token report reads to say where a step's input went.
1857
2059
  step.systemPromptChars = systemPrompt.length;
2060
+ // And each layer's size and hash, so a later reader can say which part
2061
+ // of the prompt changed between this step and the agent's last.
2062
+ step.promptLayers = promptLayers;
1858
2063
  // A retry that moved the step up a class (an evicted or OOM-killed
1859
2064
  // attempt) wins over the agent's own `size:` for the attempts after.
1860
2065
  const size = step.sizeUp ?? agentSize;
@@ -1872,11 +2077,7 @@ stepIndex) {
1872
2077
  // Populate before the first push: everything after this point may quote a
1873
2078
  // credential. Short values are skipped — a two-character secret would
1874
2079
  // redact half the English language, and anything that short is not one.
1875
- redactions = Object.entries({ ...liveSecrets, ...providerSecrets, ...providerEnv })
1876
- .filter(([, value]) => typeof value === "string" && value.length >= 8)
1877
- .map(([name, value]) => [value, name])
1878
- // Longest first, so a value containing another is replaced whole.
1879
- .sort((a, b) => b[0].length - a[0].length);
2080
+ redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
1880
2081
  // One line for the clock: which zone this step works to, and which level
1881
2082
  // set it. "Why is this dated yesterday" should be answerable from the run.
1882
2083
  push("info", `clock: ${clock.timezone} (${clock.source}) — today is ${clockEnv.FOLDRUN_DATE}`);
@@ -2115,7 +2316,14 @@ stepIndex) {
2115
2316
  ...mcpNames,
2116
2317
  ...(consults.length ? ["foldrun_agents"] : []),
2117
2318
  ...(ask ? ["foldrun_ask"] : []),
2319
+ ...(peers ? ["foldrun_peers"] : []),
2118
2320
  ];
2321
+ if (peers) {
2322
+ // The colleagues' contract, in the prompt the step reads: who runs
2323
+ // beside it this time (none, when it runs alone), and that a message
2324
+ // is a request, never a hand-over.
2325
+ prompt += `\n\n<colleagues>\n${peersPromptLine(siblings)}\n</colleagues>`;
2326
+ }
2119
2327
  // Colleagues this agent may DELEGATE to: each with its own context and
2120
2328
  // its own tools — resolved exactly as its own step's would be, then cut
2121
2329
  // to what THIS step holds (subagents.ts). Built host-side, like consults.
@@ -2286,7 +2494,21 @@ stepIndex) {
2286
2494
  ],
2287
2495
  });
2288
2496
  grant.network = { level: network.level, hosts: network.hosts };
2289
- const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
2497
+ // network_enforce: true at the account or the workspace asks the proxy
2498
+ // to refuse; the proxy adds the install's own switch and has the last
2499
+ // word (egress-proxy.ts), so false here never loosens anything.
2500
+ grant.enforce = networkEnforceWanted(readAgentsMd(accountDir(tenant))?.data?.network_enforce, readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce);
2501
+ const lease = await platform.egress.lease({
2502
+ tenant,
2503
+ runId: runId ?? "adhoc",
2504
+ grant,
2505
+ test: testRun,
2506
+ ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
2507
+ // tools: [peers] — the roster the proxy serves, and the inboxes a
2508
+ // colleague's message may be addressed to. Only when granted: a
2509
+ // step without the tools has no reason to know its siblings.
2510
+ ...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
2511
+ });
2290
2512
  // The forward door: every program in the sandbox sends through the
2291
2513
  // proxy, which holds the site list and fills bound secrets. A proxy
2292
2514
  // without the door (an older platform) leaves programs going direct.
@@ -2357,6 +2579,18 @@ stepIndex) {
2357
2579
  // carrying TEST_MODE_WITHHELD would only fail where it should have
2358
2580
  // been sunk or refused.
2359
2581
  const apiValues = testRun && lease ? placeholders : sandboxSecrets;
2582
+ // A declared secret named like the step's own model variables
2583
+ // (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, …) was spread over them below
2584
+ // and broke, or redirected, the step's model calls (FOL-19). It crosses
2585
+ // as FOLDRUN_DECLARED_<NAME> instead: the model keeps its settings, and
2586
+ // the driver gives the agent's API and script tools the value under its
2587
+ // own name (run-container.ts) — never the model's key in its place.
2588
+ const modelNamed = Object.keys(sandboxSecrets).filter(isModelEnvName);
2589
+ if (modelNamed.length) {
2590
+ push("info", `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; ` +
2591
+ "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:");
2592
+ }
2593
+ const sandboxEnvSecrets = Object.fromEntries(Object.entries(sandboxSecrets).map(([name, value]) => [isModelEnvName(name) ? `${DECLARED_ENV_PREFIX}${name}` : name, value]));
2360
2594
  const substitutedApis = apiSpecs.map((api) => ({
2361
2595
  ...api,
2362
2596
  headers: Object.fromEntries(Object.entries(api.headers).map(([k, v]) => [
@@ -2410,6 +2644,7 @@ stepIndex) {
2410
2644
  // field. A tool's own `runtime:` is part of what this step needs.
2411
2645
  runtime: runtime.spec,
2412
2646
  consults,
2647
+ ...(peers ? { peers: { self: step.agent, siblings } } : {}),
2413
2648
  ...(subagents.length ? { subagents } : {}),
2414
2649
  timeoutSec: step.timeout,
2415
2650
  budgetUsd: stepBudgetUsd,
@@ -2419,6 +2654,7 @@ stepIndex) {
2419
2654
  output: step.output,
2420
2655
  schema: resolvedSchema ?? undefined,
2421
2656
  maxTurns: step.maxTurns,
2657
+ capture,
2422
2658
  ...limitOpts,
2423
2659
  ...hookOpts,
2424
2660
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
@@ -2464,7 +2700,7 @@ stepIndex) {
2464
2700
  FOLDRUN_AGENT: step.agent,
2465
2701
  FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
2466
2702
  ...(testRun ? testModeEnv() : {}),
2467
- ...sandboxSecrets,
2703
+ ...sandboxEnvSecrets,
2468
2704
  }).filter((entry) => typeof entry[1] === "string")),
2469
2705
  emit: pushWatching,
2470
2706
  // Stamps the sandbox, so stopping this run can destroy it.
@@ -2742,6 +2978,13 @@ stepIndex) {
2742
2978
  // On this machine nobody can write in and there is no proxy: the
2743
2979
  // question goes to the terminal when there is one, else unanswered.
2744
2980
  const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
2981
+ // Colleagues in the same group reach each other over the run's own
2982
+ // bus here — no proxy, so no person can write in, but a sibling can.
2983
+ // Every grouped step gets the inbox (a pod reads the proxy's inbox
2984
+ // whatever its tools); only tools: [peers] gets the tools to send.
2985
+ const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
2986
+ const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
2987
+ const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
2745
2988
  // @file secrets become 0600 paths here (host run), cleaned up in the
2746
2989
  // finally — once, however many attempts follow.
2747
2990
  const mat = materializeFileSecrets(agentDir, stepSecrets);
@@ -2765,6 +3008,9 @@ stepIndex) {
2765
3008
  workspaceRoot,
2766
3009
  libraryRoot: libraryDir(tenant),
2767
3010
  ...(subagents.length ? { subagents } : {}),
3011
+ // The colleagues' inbox, read after every tool call — the same hook
3012
+ // a pod uses for a person's messages.
3013
+ ...(localInbox ? { inbox: localInbox } : {}),
2768
3014
  prompt,
2769
3015
  model,
2770
3016
  effort,
@@ -2779,6 +3025,7 @@ stepIndex) {
2779
3025
  ...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
2780
3026
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
2781
3027
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
3028
+ ...(peerTools ? { foldrun_peers: peerTools.server } : {}),
2782
3029
  ...(installTools ? { foldrun_install: installTools.server } : {}),
2783
3030
  ...mcpServers,
2784
3031
  },
@@ -2796,6 +3043,7 @@ stepIndex) {
2796
3043
  output: step.output,
2797
3044
  schema: resolvedSchema ?? undefined,
2798
3045
  maxTurns: step.maxTurns,
3046
+ capture,
2799
3047
  ...limitOpts,
2800
3048
  ...hookOpts,
2801
3049
  stopRequested,
@@ -3065,7 +3313,7 @@ const CREDENTIAL_POLL_MS = 2_000;
3065
3313
  * step started one second before the 09:07:52Z refresh) matched none of the
3066
3314
  * older two, so the step failed instead of waiting for the new token. */
3067
3315
  export function isAuthRefusal(text) {
3068
- return /API Error: 401|access token has (been revoked|expired)|OAuth token (has )?(been )?(revoked|expired)|Failed to authenticate/i.test(text);
3316
+ return AUTH_REFUSAL.test(text);
3069
3317
  }
3070
3318
  /**
3071
3319
  * Wait, briefly, for the mounted credential to become one we have not
@@ -3649,6 +3897,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
3649
3897
  try {
3650
3898
  // Entering the loop is what makes a run live, whatever state it was
3651
3899
  // saved in: `queued` from the queue, `awaiting-approval` from a park.
3900
+ // The first pick-up (FOL-54 step 4): startedAt to this is the time it
3901
+ // queued; and the files it runs are the workspace's latest revision
3902
+ // as it starts — a deploy's revision is its commit, and an edit through
3903
+ // /source makes one too. A re-run comes through here as well.
3904
+ run.drivenAt ??= new Date().toISOString();
3905
+ if (run.revision === undefined)
3906
+ run.revision = latestRevisionId(tenant, workspace);
3652
3907
  if (run.status !== "running") {
3653
3908
  run.status = "running";
3654
3909
  run.parkedAt = null;
@@ -4218,6 +4473,12 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4218
4473
  // twenty steps over — the shares sum to what was left. Between
4219
4474
  // groups the check above still refuses to start the next one.
4220
4475
  const launching = freshGroup.filter((s) => s.status === "pending");
4476
+ // tools: [peers] — what each step of the group is told about the
4477
+ // others, and the bus they share when they run in this process.
4478
+ const peerBus = localPeerBus();
4479
+ const siblingsOf = (step) => launching
4480
+ .filter((s) => s !== step)
4481
+ .map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
4221
4482
  // An agent's own `budget:` — the most it may spend in one run — is
4222
4483
  // read at launch, so a cap raised mid-run applies to the next step.
4223
4484
  const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
@@ -4249,16 +4510,49 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4249
4510
  pools.set(key, (pool = slotPool(step.parallel)));
4250
4511
  return pool;
4251
4512
  };
4513
+ // One instance of each agent goes first so its siblings read the
4514
+ // prompt cache it wrote — see warmGate. The claim is the callback's
4515
+ // first statement, before any await; the wait comes before the
4516
+ // pool, so nothing holds a slot while it waits.
4517
+ const gate = warmGate(launching);
4252
4518
  await Promise.all(launching
4253
4519
  .map(async (step) => {
4520
+ const lead = gate.claim(step);
4521
+ if (lead) {
4522
+ 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` });
4523
+ save();
4524
+ await lead;
4525
+ }
4254
4526
  const pool = poolFor(step);
4255
4527
  if (pool)
4256
4528
  await pool.acquire();
4257
4529
  try {
4258
- await launchStep(step);
4530
+ // A stop that arrived while this copy waited — for the
4531
+ // lead's reply or for a slot — must not start it: the
4532
+ // between-groups check runs only once this group is over,
4533
+ // and save() is what carries the flag in from the record.
4534
+ if (lead || pool) {
4535
+ save();
4536
+ if (run.stopRequested) {
4537
+ step.status = "skipped";
4538
+ step.skipReason = "run stopped";
4539
+ step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
4540
+ save();
4541
+ gate.release(step);
4542
+ return;
4543
+ }
4544
+ }
4545
+ await gate.run(step, () => launchStep(step));
4259
4546
  }
4260
4547
  finally {
4261
4548
  pool?.release();
4549
+ // Every attempt is over: off its siblings' rosters, so a
4550
+ // message to it is not reported delivered and never read.
4551
+ // Here, not per attempt — a step between retries is still
4552
+ // one its colleagues can reach.
4553
+ const index = run.steps.indexOf(step);
4554
+ peerBus.leave(index);
4555
+ await platform.egress.stepEnded?.({ tenant, runId: run.id, step: index }).catch(() => undefined);
4262
4556
  }
4263
4557
  }));
4264
4558
  async function launchStep(step) {
@@ -4304,7 +4598,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4304
4598
  () => {
4305
4599
  save();
4306
4600
  return run.stopRequested === true;
4307
- }, flowTimezone, run.steps.indexOf(step)).finally(() => reserved.delete(step));
4601
+ }, flowTimezone, run.steps.indexOf(step), siblingsOf(step), peerBus).finally(() => reserved.delete(step));
4308
4602
  // runStep mutates step.status; read it through a widened local
4309
4603
  // so TS doesn't keep the "running" narrowing from above.
4310
4604
  const outcome = step.status;
@@ -4767,26 +5061,7 @@ function lastActivity(run) {
4767
5061
  }
4768
5062
  return latest;
4769
5063
  }
4770
- /**
4771
- * Close out runs abandoned by a process that is no longer here.
4772
- *
4773
- * Runs execute in the server process, and nothing writes a terminal status
4774
- * when that process goes away — so a deploy, a crash or a Ctrl-C during a run
4775
- * leaves `status: "running"` on disk forever. The run history then contains
4776
- * entries that are not merely stale but false: they claim to be in progress,
4777
- * the dashboard renders them as live, and no one can tell them apart from a
4778
- * step that is genuinely still thinking.
4779
- *
4780
- * Called at startup, before anything reads run history. A run still being
4781
- * worked on by a live process is protected by the idle window, not by a lock —
4782
- * so this is safe to call while runs are in flight, and deliberately
4783
- * conservative about what it declares dead.
4784
- *
4785
- * A run genuinely waiting for a person is left alone: that state legitimately
4786
- * outlives any process. A run whose person already answered is not waiting,
4787
- * and is picked back up — see below.
4788
- */
4789
- export function reconcileRuns(tenant, now = Date.now(), live) {
5064
+ export function reconcileRuns(tenant, now = Date.now(), live, onEnd) {
4790
5065
  const closed = [];
4791
5066
  for (const workspace of listWorkspaces(tenant)) {
4792
5067
  // Live runs only, read whole — every other status is passed over below,
@@ -4823,7 +5098,13 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
4823
5098
  // Park at any *later* gate rather than poll: reconcile runs inside
4824
5099
  // long-lived server processes, where a 24h file-poll is exactly the
4825
5100
  // slot-burning the queue exists to avoid.
4826
- void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true });
5101
+ void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
5102
+ .then(() => {
5103
+ const after = readRun(tenant, workspace.name, run.id);
5104
+ if (onEnd && after && (after.status === "completed" || after.status === "failed"))
5105
+ return onEnd(tenant, workspace.name, run.id);
5106
+ })
5107
+ .catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after resume:`, err));
4827
5108
  closed.push({
4828
5109
  runId: run.id,
4829
5110
  tenant,
@@ -4870,6 +5151,11 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
4870
5151
  // a sandbox we cannot reach must not stop the reconcile
4871
5152
  }
4872
5153
  closed.push({ runId: run.id, tenant, workspace: workspace.name, action: "closed", interrupted });
5154
+ if (onEnd) {
5155
+ void Promise.resolve()
5156
+ .then(() => onEnd(tenant, workspace.name, run.id))
5157
+ .catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after close:`, err));
5158
+ }
4873
5159
  }
4874
5160
  }
4875
5161
  return closed;
@@ -4881,8 +5167,8 @@ export function reconcileRuns(tenant, now = Date.now(), live) {
4881
5167
  * one hard-coded account left every other account's interrupted runs marked
4882
5168
  * `running` forever, with no process left to finish them.
4883
5169
  */
4884
- export function reconcileAllRuns(now = Date.now(), live) {
4885
- return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
5170
+ export function reconcileAllRuns(now = Date.now(), live, onEnd) {
5171
+ return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live, onEnd));
4886
5172
  }
4887
5173
  /**
4888
5174
  * Wait for a run to stop being in progress.