@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
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 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,7 @@ 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";
21
24
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
22
25
  import { EGRESS_ENV, MODEL_KEY_NAME, addGrant, hostOf, placeholderNames, proxyModelEnv, unsubstitute, type EgressGrant } from "./egress.ts";
23
26
  import { explainRefusal, isRefusalStatus, refusalFromLine, supplyNote, type SupplyState } from "./refusal.ts";
@@ -33,7 +36,7 @@ const isolatedRun = () => {
33
36
  };
34
37
  import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
35
38
  import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools, type SubagentSpec } from "./subagents.ts";
36
- import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool } from "./operator.ts";
39
+ import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine, type PeerInfo } from "./operator.ts";
37
40
  import { sendPlainNotification } from "./notify.ts";
38
41
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.ts";
39
42
  import { refNames } from "./refs.ts";
@@ -83,9 +86,10 @@ import {
83
86
  runCost,
84
87
  } from "./store.ts";
85
88
  import { canReadBack } from "./tool-output.ts";
89
+ import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
86
90
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
87
91
  import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
88
- import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
92
+ import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
89
93
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
90
94
  import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
91
95
  import { attachOperations, prefetchOpenApi } from "./openapi.ts";
@@ -210,6 +214,101 @@ export function slotPool(size: number): { acquire: () => Promise<void>; release:
210
214
  };
211
215
  }
212
216
 
217
+ /** Steps whose model has begun its first response — the step's executor
218
+ * says so the moment the stream starts (EventExtra.responding). Memory
219
+ * only: it is scheduling, not something the run record needs. */
220
+ const respondingSteps = new WeakSet<StepRecord>();
221
+ export function markResponding(step: StepRecord): void {
222
+ respondingSteps.add(step);
223
+ }
224
+
225
+ /**
226
+ * Is this step's prompt in the cache yet? An entry is readable from the
227
+ * moment the response to the request that wrote it begins streaming
228
+ * (Anthropic's prompt-caching docs), which is what `responding` marks.
229
+ * The first text, tool call or recorded model call says the same a little
230
+ * later, for an executor that cannot see the stream (the stub, an older
231
+ * driver). Resolves on the first of those, or when the step is over
232
+ * without one (a refusal before the first turn, a failed start).
233
+ */
234
+ export function untilFirstReply(step: StepRecord, done: Promise<unknown>, pollMs = 250): Promise<void> {
235
+ const replied = () =>
236
+ respondingSteps.has(step) || step.events.some((e) => e.type === "text" || e.type === "tool") || (step.calls?.length ?? 0) > 0;
237
+ return new Promise<void>((resolve) => {
238
+ let finished = false;
239
+ const stop = () => {
240
+ finished = true;
241
+ resolve();
242
+ };
243
+ done.then(stop, stop);
244
+ const tick = () => {
245
+ if (finished) return;
246
+ if (replied()) return stop();
247
+ setTimeout(tick, pollMs).unref?.();
248
+ };
249
+ tick();
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Steps of one agent launched together share a prompt prefix — tools and
255
+ * system prompt — and that is what the model's cache keeps. The cache is
256
+ * written by the first request that carries a prefix and readable only
257
+ * once that response has begun, so N copies started at once all write it
258
+ * and none reads: a fan-out of twenty paid twenty cold prefixes. The gate
259
+ * lets the first instance of each agent go alone and holds its siblings
260
+ * until its first response has begun (or it ended); then they all start
261
+ * and read what it wrote — Claude Code holds a workflow's fan-out the same
262
+ * way. Not a fixed delay: a sandbox takes seconds to start before its
263
+ * first model call, so a timer from launch would release the siblings
264
+ * before there was anything to read. Keyed by agent and model, since a
265
+ * cache is per model.
266
+ */
267
+ export function warmGate(launching: StepRecord[], pollMs = 250) {
268
+ const key = (s: StepRecord) => `${s.agent}\u0000${s.model ?? ""}`;
269
+ const counts = new Map<string, number>();
270
+ for (const s of launching) counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
271
+ const leads = new Map<string, { opened: Promise<void>; open: () => void }>();
272
+ const isLead = new WeakSet<StepRecord>();
273
+ return {
274
+ /** Claim the step's place — synchronously, before anything awaits, or
275
+ * the copies that reach their first await together would all find no
276
+ * lead and all go (a `parallel:` pool's acquire was exactly that).
277
+ * The first of its agent leads and gets null; a later one gets the
278
+ * lead's gate to await before it starts; a lone step gets null too. */
279
+ claim(step: StepRecord): Promise<void> | null {
280
+ const k = key(step);
281
+ if ((counts.get(k) ?? 0) < 2) return null;
282
+ const lead = leads.get(k);
283
+ if (lead) return lead.opened;
284
+ let open!: () => void;
285
+ const opened = new Promise<void>((r) => (open = r));
286
+ leads.set(k, { opened, open });
287
+ isLead.add(step);
288
+ return null;
289
+ },
290
+ /** A lead that will not run after all (the run was stopped while it
291
+ * waited for a pool slot) still opens its gate — or its siblings,
292
+ * which also wake to the stop, would wait on it forever. */
293
+ release(step: StepRecord): void {
294
+ if (isLead.has(step)) leads.get(key(step))!.open();
295
+ },
296
+ /** Run the step; a lead opens its agent's gate on its first reply (or
297
+ * its end), whatever else happens to it. */
298
+ async run(step: StepRecord, launch: () => Promise<void>): Promise<void> {
299
+ if (!isLead.has(step)) return launch();
300
+ const { open } = leads.get(key(step))!;
301
+ const done = launch();
302
+ try {
303
+ await untilFirstReply(step, done, pollMs);
304
+ } finally {
305
+ open();
306
+ }
307
+ await done;
308
+ },
309
+ };
310
+ }
311
+
213
312
  export interface DiscoveredSkill {
214
313
  name: string;
215
314
  description: string;
@@ -235,12 +334,9 @@ const toTags = (v: unknown): string[] =>
235
334
  // The Agent Skills client guide recommends quoting such values and retrying
236
335
  // rather than dropping the skill, so a cross-client skill still loads here.
237
336
  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();
337
+ // gray-matter caches a *failed* parse as a hollow success; ./matter.ts
338
+ // parses every call, so the throw below is deterministic and the fallback
339
+ // fires every time (it used to clear gray-matter's cache here for that).
244
340
  try {
245
341
  return matter(raw) as { data: Record<string, unknown> };
246
342
  } catch {
@@ -427,6 +523,10 @@ export function workspaceRuntimePlan(
427
523
  return [...byFp.values()];
428
524
  }
429
525
 
526
+ /** The most tool content one step records (FOL-54 step 4): about 60 calls
527
+ * at the per-part cap; past it, metadata only. */
528
+ const STEP_CAPTURE_LIMIT = 4_000_000;
529
+
430
530
  function workspaceFrontmatter(agentDir: string, tenant: string): Record<string, unknown> {
431
531
  return {
432
532
  ...(readAgentsMd(accountDir(tenant))?.data ?? {}),
@@ -758,7 +858,11 @@ export function agentContext(
758
858
  const { data: front, content: body } = matter(
759
859
  fs.readFileSync(path.join(agentDir, "agent.md"), "utf8"),
760
860
  );
761
- const parts = [resolveDocLinks(body.trim(), path.resolve(agentDir, "..", ".."))];
861
+ // The prompt's sections, by name and by how often they change. Added in
862
+ // the order they are computed; assembled stable first, so the model's
863
+ // cache keeps the agent's half across steps and runs (prompt-layers.ts).
864
+ const parts = new PromptLayers();
865
+ parts.add("persona", resolveDocLinks(body.trim(), path.resolve(agentDir, "..", "..")));
762
866
 
763
867
  // Where the agent is standing. Every path below this line is relative to the
764
868
  // agent's own directory, and a run showed why that has to be said rather
@@ -780,14 +884,14 @@ export function agentContext(
780
884
  const languageSet = language.from !== "default" && language.from !== "env";
781
885
  const regionSet = region.from !== "none" && region.from !== "env" && region.from !== "language";
782
886
  if (languageSet || regionSet) {
783
- parts.push(
887
+ parts.add("locale",
784
888
  `# Locale\n\n${localeProse(locale, languageName(language.language), regionName(region.region))} ` +
785
889
  `Write in ${languageName(language.language)} unless the step you are given says otherwise; ` +
786
890
  `use these currency, units and calendar conventions in anything you produce. ` +
787
891
  `Your tools already know: a search asks in it and for this country, the browser reports it, a fetch requests it.`,
788
892
  );
789
893
  }
790
- parts.push(
894
+ parts.add("where",
791
895
  `# Where you are\n\n` +
792
896
  `Your working directory is \`agents/${path.basename(agentDir)}/\` inside the ` +
793
897
  `\`${workspace}\` workspace. Every path in this prompt is relative to it — ` +
@@ -832,7 +936,7 @@ export function agentContext(
832
936
  // Shared context before anything derived — an account or workspace rule is
833
937
  // background the rest of the prompt is read against, not an afterthought.
834
938
  const sharedContext = sharedInstructions(agentDir, tenant);
835
- if (sharedContext) parts.push(sharedContext);
939
+ if (sharedContext) parts.add("agents.md", sharedContext);
836
940
 
837
941
  // Skills follow the open Agent Skills format (agentskills.io): a folder
838
942
  // with SKILL.md, optionally bundling its own scripts/ and references/. The
@@ -871,7 +975,7 @@ export function agentContext(
871
975
  (bundles.length ? ` (also bundles ${bundles.join(", ")})` : "")
872
976
  );
873
977
  });
874
- parts.push(
978
+ parts.add("skills",
875
979
  `# Skills\n\nCapabilities available to you. Each line is a summary — when a task matches one, ` +
876
980
  `read its file for the full instructions before proceeding:\n\n${lines.join("\n")}` +
877
981
  (withheld ? `\n\n(${withheld} further skills exist but do not apply to this run.)` : ""),
@@ -893,11 +997,13 @@ export function agentContext(
893
997
  : "";
894
998
  const projectMem = workspaceMemoryIndex(tenant, workspace);
895
999
  const shared = libraryMemoryIndex(tenant);
896
- parts.push(
1000
+ // Dynamic: agents write memory, so the index moves between runs.
1001
+ parts.add("memory",
897
1002
  `# 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
1003
  (projectMem ? `\n\nShared across this workspace:\n\n${projectMem}` : "") +
899
1004
  (shared ? `\n\nShared across every workspace:\n\n${shared}` : "") +
900
1005
  howTo,
1006
+ { dynamic: true },
901
1007
  );
902
1008
  }
903
1009
 
@@ -914,7 +1020,7 @@ export function agentContext(
914
1020
  projectKnowledge && `Shared across this workspace:\n\n${projectKnowledge}`,
915
1021
  sharedKnowledge && `Shared across every workspace:\n\n${sharedKnowledge}`,
916
1022
  ].filter(Boolean);
917
- parts.push(
1023
+ parts.add("knowledge",
918
1024
  `# Knowledge — what you were given\n\nReference material. Open a file when a task depends ` +
919
1025
  `on it rather than guessing at its contents. This is read-only: it is maintained by ` +
920
1026
  `people, not by you — if you learn something new, write it to memory/ instead.\n\n` +
@@ -947,7 +1053,7 @@ export function agentContext(
947
1053
  shared.length ? `Shared across this workspace:\n${shared.join("\n")}` : null,
948
1054
  global.length ? `From the account library (read-only):\n${global.join("\n")}` : null,
949
1055
  ].filter(Boolean);
950
- parts.push(
1056
+ parts.add("scripts",
951
1057
  `# Scripts\n\nTooling you can run with bash from your working directory:\n\n${sections.join("\n\n")}\n\n` +
952
1058
  `Read a script before running it if you are unsure what it does. Credentials your ` +
953
1059
  `scripts need are already in the environment as the secrets listed below.`,
@@ -965,7 +1071,8 @@ export function agentContext(
965
1071
  // file to size it against, not before.
966
1072
  const state = listDir(path.join(agentDir, "..", "..", "state"), "workspace/state/");
967
1073
  if (state.length) {
968
- parts.push(
1074
+ // Dynamic: runs add and remove state files.
1075
+ parts.add("state",
969
1076
  `# State — what you carry between runs\n\n` +
970
1077
  `What this workspace keeps across runs: a count, a cursor, where you got to. ` +
971
1078
  `Read and write these directly, and update them before you finish so the next ` +
@@ -975,6 +1082,7 @@ export function agentContext(
975
1082
  `same editor as everything else — data formats are allowed here, not preferred.\n\n` +
976
1083
  `This is not memory. A fact you learned goes in memory/, where it is indexed and ` +
977
1084
  `its provenance recorded — state is the bookmark, not the lesson.`,
1085
+ { dynamic: true },
978
1086
  );
979
1087
  }
980
1088
 
@@ -1057,7 +1165,7 @@ export function agentContext(
1057
1165
  const keepIn = { dir: agentDir, readable: () => readsFiles };
1058
1166
  const apiTools = buildApiTools(tenant, apis, workspace, undefined, keepIn);
1059
1167
  if (apiTools.promptLines.length) {
1060
- parts.push(`# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
1168
+ parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
1061
1169
  }
1062
1170
 
1063
1171
  // Secrets an agent declared become env vars for its scripts and bash.
@@ -1214,6 +1322,10 @@ export function agentContext(
1214
1322
  name: spec.name,
1215
1323
  maxTokensParam: preset?.maxTokensParam,
1216
1324
  reasoningEffort: preset?.reasoningEffort,
1325
+ // The agent's prompts share an opening; the key says so to an
1326
+ // endpoint that routes its cache by one. Only where the preset
1327
+ // says the parameter is understood — elsewhere it is a 400.
1328
+ cacheKey: preset?.promptCacheKey ? promptCacheKey(tenant, workspace, path.basename(agentDir)) : undefined,
1217
1329
  });
1218
1330
  // What the run is actually pointed at, in one line: the endpoint, the
1219
1331
  // tiers this gateway renames, and whether the credential landed.
@@ -1404,12 +1516,15 @@ export function agentContext(
1404
1516
  exec,
1405
1517
  keepIn.readable,
1406
1518
  );
1407
- parts.push(
1519
+ // Dynamic: the date moves daily, and would otherwise invalidate every
1520
+ // layer behind it once a day.
1521
+ parts.add("clock",
1408
1522
  `# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
1409
1523
  `write or check — it is the workspace's calendar, not the container's clock.`,
1524
+ { dynamic: true },
1410
1525
  );
1411
1526
  if (scriptTools.promptLines.length) {
1412
- parts.push(
1527
+ parts.add("script-tools",
1413
1528
  `# Tools from scripts\n\nCall these directly — they run workspace scripts for you. Each is called by its full name, ` +
1414
1529
  `\`mcp__foldrun_scripts__<name>\` (\`web\` is \`mcp__foldrun_scripts__web\`); a bare name is refused:\n\n${scriptTools.promptLines.join("\n")}`,
1415
1530
  );
@@ -1420,20 +1535,20 @@ export function agentContext(
1420
1535
  // reddit-desk validator step on 6 Oct 2026. Say it before the first call.
1421
1536
  if (builtins.length) {
1422
1537
  const named = builtins.map((b) => b.split("="));
1423
- parts.push(
1538
+ parts.add("web",
1424
1539
  `# Web search and fetch on this run\n\n` +
1425
1540
  named.map(([action, tool]) => `- To ${action}, call **${tool}** directly. \`web\` with action=${action} is switched off on this run.`).join("\n") +
1426
1541
  `\n\nThe web tool's other actions work as usual.`,
1427
1542
  );
1428
1543
  }
1429
1544
  if (declared.length) {
1430
- parts.push(
1545
+ parts.add("secrets",
1431
1546
  `# Secrets\n\nAvailable to your scripts as environment variables: ${declared.join(", ")}. ` +
1432
1547
  `Never print, echo, or write them to a file.`,
1433
1548
  );
1434
1549
  }
1435
1550
 
1436
- parts.push(
1551
+ parts.add("rules",
1437
1552
  "Write deliverables for people to workspace/storage/ (kept and downloadable); write working text for later steps to outputs/; " +
1438
1553
  "what the next run needs goes to workspace/state/. Paths are relative to your agent directory, or start with workspace/ " +
1439
1554
  "for the workspace root (workspace/storage/ and the older ../../storage/ are the same place). There is no /tmp for you: a " +
@@ -1453,7 +1568,7 @@ export function agentContext(
1453
1568
  // run, and an agent that opened with "I'll start by cloning the repo" made
1454
1569
  // all three useless. Asking here rather than in each agent.md means every
1455
1570
  // agent obeys it, including the ones written before the summary existed.
1456
- parts.push(
1571
+ parts.add("rules",
1457
1572
  "Open your reply with one sentence saying what you concluded or produced — the outcome, " +
1458
1573
  "not your plan. Everything else follows it. That sentence is what a person sees in the " +
1459
1574
  "run list and in the notification, without opening the run.",
@@ -1500,6 +1615,7 @@ export function agentContext(
1500
1615
  let wantHistory = false;
1501
1616
  let wantDesks = false;
1502
1617
  let wantAsk = false;
1618
+ let wantPeers = false;
1503
1619
 
1504
1620
  for (const ref of toolRefs(front)) {
1505
1621
  const toolName = ref.name;
@@ -1532,12 +1648,13 @@ export function agentContext(
1532
1648
  } else if (TOOL_MAP[toolName]) {
1533
1649
  allowed.push(...TOOL_MAP[toolName]);
1534
1650
  if (available[toolName]) shadowed.push(toolName);
1535
- } else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask") {
1651
+ } else if (toolName === "search" || toolName === "history" || toolName === "desks" || toolName === "ask" || toolName === "peers") {
1536
1652
  // The platform's own groups, served in-process — built below, once
1537
1653
  // the roots and the records are known.
1538
1654
  if (toolName === "search") wantSearch = true;
1539
1655
  else if (toolName === "history") wantHistory = true;
1540
1656
  else if (toolName === "ask") wantAsk = true;
1657
+ else if (toolName === "peers") wantPeers = true;
1541
1658
  else wantDesks = true;
1542
1659
  if (available[toolName]) shadowed.push(toolName);
1543
1660
  } else if (toolName === "install") {
@@ -1593,10 +1710,16 @@ export function agentContext(
1593
1710
  const ask = wantAsk ? { timeoutSec: askTimeoutSec(front) } : null;
1594
1711
  if (ask) {
1595
1712
  allowed.push(ASK_TOOL);
1596
- parts.push(`# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1713
+ parts.add("ask", `# Asking a person\n\n${askPromptLine(ask.timeoutSec)}`);
1597
1714
  }
1715
+ // tools: [peers] — peers_list and peers_message, to the other steps of
1716
+ // this step's parallel group. Who they are is known only when the group
1717
+ // launches, so the grant is here and the roster, the server and the
1718
+ // prompt's line are the step's (runStep).
1719
+ const peers = wantPeers;
1720
+ if (peers) allowed.push(...PEER_TOOLS);
1598
1721
  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")}`);
1722
+ parts.add("finding", `# Finding things\n\n${[...searchTools.promptLines, ...historyTools.promptLines, ...deskTools.promptLines].join("\n")}`);
1600
1723
  }
1601
1724
  // An MCP server declares its tools when it connects, so they can't be listed
1602
1725
  // ahead of time. Server-level specs cover them: granting the server grants
@@ -1618,6 +1741,7 @@ export function agentContext(
1618
1741
  if (denied.includes("install")) denied.push(INSTALL_TOOL);
1619
1742
  const finalAllowed = allowed.filter((t) => !denied.includes(t));
1620
1743
  readsFiles = canReadBack(finalAllowed);
1744
+ const prompt = parts.assemble();
1621
1745
 
1622
1746
  return {
1623
1747
  front,
@@ -1625,7 +1749,9 @@ export function agentContext(
1625
1749
  /** The zone, where it came from, and anything unreadable on the way —
1626
1750
  * reported by the step, not here, so it lands in the run's trace. */
1627
1751
  clock,
1628
- systemPrompt: parts.join("\n\n"),
1752
+ systemPrompt: prompt.text,
1753
+ /** What the record keeps of each layer of that prompt. */
1754
+ promptLayers: prompt.layers,
1629
1755
  allowed: finalAllowed,
1630
1756
  disabled,
1631
1757
  unknownTools,
@@ -1649,6 +1775,7 @@ export function agentContext(
1649
1775
  historyTools,
1650
1776
  deskTools,
1651
1777
  ask,
1778
+ peers,
1652
1779
  // `spec` is the MERGED declaration — the agent's own block or its
1653
1780
  // workspace's, plus every granted tool's. The isolated path builds its
1654
1781
  // runtime from this; passing the agent's frontmatter alone silently drops
@@ -1906,6 +2033,12 @@ async function runStep(
1906
2033
  * person's message to this step reaches this step's own inbox and not a
1907
2034
  * sibling's running in the same group. Undefined outside a flow. */
1908
2035
  stepIndex?: number,
2036
+ /** The other steps launched with this one (its parallel group), for
2037
+ * tools: [peers]. Empty when it runs alone. */
2038
+ siblings: PeerInfo[] = [],
2039
+ /** The in-process colleagues' bus for this group (localPeerBus), when
2040
+ * the step runs in this process rather than a pod. */
2041
+ peerBus?: ReturnType<typeof localPeerBus>,
1909
2042
  ) {
1910
2043
  // Secret values are injected into scripts as environment variables and
1911
2044
  // substituted into API headers, so a model that reads one back — from a
@@ -1952,9 +2085,54 @@ async function runStep(
1952
2085
  }
1953
2086
  };
1954
2087
 
2088
+ // What this workspace records of each step (FOL-54 step 4): `capture:` in
2089
+ // AGENTS.md — every model call always; each tool call's input and output
2090
+ // unless it says `metadata`.
2091
+ const capture: "metadata" | "full" = workspaceFrontmatter(agentDir, tenant).capture === "metadata" ? "metadata" : "full";
2092
+ // A step that pours out content stops being recorded past this — its
2093
+ // calls keep their names, sizes and times (FOL-54 step 4).
2094
+ let capturedChars = 0;
2095
+ let captureFull = false;
1955
2096
  const push = (type: StepRecord["events"][number]["type"], text: string, extra?: EventExtra) => {
1956
2097
  const t = new Date().toISOString();
1957
- step.events.push({ t, type, text: redact(text), ...extra });
2098
+ // The model has begun its first response: held only in memory, for
2099
+ // the fan-out gate (warmGate). Not a line in the trace.
2100
+ if (extra?.responding) {
2101
+ markResponding(step);
2102
+ return;
2103
+ }
2104
+ // A model call is a span on the step, not a line in its trace.
2105
+ // Numbered here, across attempts: a retry's calls follow the first
2106
+ // attempt's, and an id-less turn of one attempt never overwrites
2107
+ // another's. No save of its own — a turn's blocks arrive as several
2108
+ // messages, and the next event saves them all.
2109
+ if (extra?.turn) {
2110
+ const calls = (step.calls ??= []);
2111
+ // The loop counts attempts from 1; a call of the first carries none.
2112
+ const attempt = Math.max(1, step.attempts ?? 1);
2113
+ const at = calls.findIndex((c) => c.id === extra.turn!.id && (c.attempt ?? 1) === attempt);
2114
+ const call = { ...extra.turn, n: at >= 0 ? calls[at]!.n : calls.length + 1, ...(attempt > 1 ? { attempt } : {}) };
2115
+ if (at >= 0) calls[at] = call;
2116
+ else calls.push(call);
2117
+ return;
2118
+ }
2119
+ // A tool call's content goes beside the run, masked (capture.ts), and the
2120
+ // event keeps only that it was recorded.
2121
+ let rest: EventExtra | undefined = extra;
2122
+ if (extra?.io) {
2123
+ const { io, ...others } = extra;
2124
+ rest = others;
2125
+ const size = (io.input?.length ?? 0) + (io.output?.length ?? 0);
2126
+ if (runId && extra.call && capture === "full" && capturedChars + size > STEP_CAPTURE_LIMIT && !captureFull) {
2127
+ captureFull = true;
2128
+ 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` });
2129
+ }
2130
+ if (runId && extra.call && capture === "full" && !captureFull && recordCallIo(path.resolve(agentDir, "..", ".."), runId, extra.call, text, io, redact)) {
2131
+ capturedChars += size;
2132
+ rest = { ...others, captured: true };
2133
+ }
2134
+ }
2135
+ step.events.push({ t, type, text: redact(text), ...rest });
1958
2136
  // A person in the loop: the question, its answer, a message delivered —
1959
2137
  // onto the step, so the run page, the API and history show them.
1960
2138
  if (extra?.operator) {
@@ -1988,7 +2166,26 @@ async function runStep(
1988
2166
  step.status = "completed";
1989
2167
  // A stub answer may open with `cost: 0.25` — a pretend price, so the
1990
2168
  // parts of orchestration that watch money (budget:) are testable too.
1991
- const answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
2169
+ let answer = (answers[Math.min(call, answers.length - 1)] ?? "").trim();
2170
+ // `call: <tool> <input> => <output>` lines: a pretend tool call, sent
2171
+ // through the same push a real step's events take — with its model call
2172
+ // and its content — so what the runner records of a step is testable.
2173
+ answer = answer
2174
+ .split("\n")
2175
+ .filter((line) => {
2176
+ const m = /^call: (\S+) (.*?) => (.*)$/.exec(line);
2177
+ if (!m) return true;
2178
+ const id = `toolu_stub${(step.calls?.length ?? 0) + 1}`;
2179
+ // Twice under one id, as the SDK sends a turn's blocks: one row.
2180
+ 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 };
2181
+ push("info", "", { turn });
2182
+ push("info", "", { turn: { ...turn, stop: "tool_use", tools: 1 } });
2183
+ push("tool", m[1]!, { call: id, ...(capture ? { io: { input: m[2]!, inputChars: m[2]!.length } } : {}) });
2184
+ push("tool", m[1]!, { call: id, ms: 3, io: { output: m[3]!, outputChars: m[3]!.length } });
2185
+ return false;
2186
+ })
2187
+ .join("\n")
2188
+ .trim();
1992
2189
  const priced = answer.match(/^cost:\s*([\d.]+)\n?/);
1993
2190
  const body = (priced ? answer.slice(priced[0].length) : answer).trim();
1994
2191
  // A `===` line inside an answer separates earlier turns from the final
@@ -2027,7 +2224,7 @@ async function runStep(
2027
2224
  // step: the generic HTTP tool still works without the typed ones.
2028
2225
  for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant))) push("error", `openapi: ${w}`);
2029
2226
  const {
2030
- front, clockEnv, clock, systemPrompt, allowed, disabled, apiTools, scriptTools,
2227
+ front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools,
2031
2228
  secretEnv, secretScopes, missingSecrets, missingTools, runtime,
2032
2229
  unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames,
2033
2230
  apiSpecs, scriptSpecs, brokenTools, size: agentSize,
@@ -2035,6 +2232,7 @@ async function runStep(
2035
2232
  searchChoice, fetchChoice, actionChoices, web, language, region,
2036
2233
  fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools,
2037
2234
  ask,
2235
+ peers,
2038
2236
  translator, fallbackTranslator,
2039
2237
  outward,
2040
2238
  } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
@@ -2043,6 +2241,9 @@ async function runStep(
2043
2241
  // Sent again on every turn; with turns and the tools' result sizes, what
2044
2242
  // a token report reads to say where a step's input went.
2045
2243
  step.systemPromptChars = systemPrompt.length;
2244
+ // And each layer's size and hash, so a later reader can say which part
2245
+ // of the prompt changed between this step and the agent's last.
2246
+ step.promptLayers = promptLayers;
2046
2247
  // A retry that moved the step up a class (an evicted or OOM-killed
2047
2248
  // attempt) wins over the agent's own `size:` for the attempts after.
2048
2249
  const size = step.sizeUp ?? agentSize;
@@ -2062,11 +2263,7 @@ async function runStep(
2062
2263
  // Populate before the first push: everything after this point may quote a
2063
2264
  // credential. Short values are skipped — a two-character secret would
2064
2265
  // 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);
2266
+ redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
2070
2267
 
2071
2268
  // One line for the clock: which zone this step works to, and which level
2072
2269
  // set it. "Why is this dated yesterday" should be answerable from the run.
@@ -2314,7 +2511,14 @@ async function runStep(
2314
2511
  ...mcpNames,
2315
2512
  ...(consults.length ? ["foldrun_agents"] : []),
2316
2513
  ...(ask ? ["foldrun_ask"] : []),
2514
+ ...(peers ? ["foldrun_peers"] : []),
2317
2515
  ];
2516
+ if (peers) {
2517
+ // The colleagues' contract, in the prompt the step reads: who runs
2518
+ // beside it this time (none, when it runs alone), and that a message
2519
+ // is a request, never a hand-over.
2520
+ prompt += `\n\n<colleagues>\n${peersPromptLine(siblings)}\n</colleagues>`;
2521
+ }
2318
2522
 
2319
2523
  // Colleagues this agent may DELEGATE to: each with its own context and
2320
2524
  // its own tools — resolved exactly as its own step's would be, then cut
@@ -2483,7 +2687,24 @@ async function runStep(
2483
2687
  ],
2484
2688
  });
2485
2689
  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 } : {}) });
2690
+ // network_enforce: true at the account or the workspace asks the proxy
2691
+ // to refuse; the proxy adds the install's own switch and has the last
2692
+ // word (egress-proxy.ts), so false here never loosens anything.
2693
+ grant.enforce = networkEnforceWanted(
2694
+ readAgentsMd(accountDir(tenant))?.data?.network_enforce,
2695
+ readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce,
2696
+ );
2697
+ const lease = await platform.egress.lease({
2698
+ tenant,
2699
+ runId: runId ?? "adhoc",
2700
+ grant,
2701
+ test: testRun,
2702
+ ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
2703
+ // tools: [peers] — the roster the proxy serves, and the inboxes a
2704
+ // colleague's message may be addressed to. Only when granted: a
2705
+ // step without the tools has no reason to know its siblings.
2706
+ ...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
2707
+ });
2487
2708
  // The forward door: every program in the sandbox sends through the
2488
2709
  // proxy, which holds the site list and fills bound secrets. A proxy
2489
2710
  // without the door (an older platform) leaves programs going direct.
@@ -2556,6 +2777,23 @@ async function runStep(
2556
2777
  // carrying TEST_MODE_WITHHELD would only fail where it should have
2557
2778
  // been sunk or refused.
2558
2779
  const apiValues = testRun && lease ? placeholders : sandboxSecrets;
2780
+ // A declared secret named like the step's own model variables
2781
+ // (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, …) was spread over them below
2782
+ // and broke, or redirected, the step's model calls (FOL-19). It crosses
2783
+ // as FOLDRUN_DECLARED_<NAME> instead: the model keeps its settings, and
2784
+ // the driver gives the agent's API and script tools the value under its
2785
+ // own name (run-container.ts) — never the model's key in its place.
2786
+ const modelNamed = Object.keys(sandboxSecrets).filter(isModelEnvName);
2787
+ if (modelNamed.length) {
2788
+ push(
2789
+ "info",
2790
+ `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; ` +
2791
+ "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:",
2792
+ );
2793
+ }
2794
+ const sandboxEnvSecrets = Object.fromEntries(
2795
+ Object.entries(sandboxSecrets).map(([name, value]) => [isModelEnvName(name) ? `${DECLARED_ENV_PREFIX}${name}` : name, value]),
2796
+ );
2559
2797
  const substitutedApis = apiSpecs.map((api) => ({
2560
2798
  ...api,
2561
2799
  headers: Object.fromEntries(
@@ -2612,6 +2850,7 @@ async function runStep(
2612
2850
  // field. A tool's own `runtime:` is part of what this step needs.
2613
2851
  runtime: runtime.spec,
2614
2852
  consults,
2853
+ ...(peers ? { peers: { self: step.agent, siblings } } : {}),
2615
2854
  ...(subagents.length ? { subagents } : {}),
2616
2855
  timeoutSec: step.timeout,
2617
2856
  budgetUsd: stepBudgetUsd,
@@ -2621,6 +2860,7 @@ async function runStep(
2621
2860
  output: step.output,
2622
2861
  schema: resolvedSchema ?? undefined,
2623
2862
  maxTurns: step.maxTurns,
2863
+ capture,
2624
2864
  ...limitOpts,
2625
2865
  ...hookOpts,
2626
2866
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
@@ -2667,7 +2907,7 @@ async function runStep(
2667
2907
  FOLDRUN_AGENT: step.agent,
2668
2908
  FOLDRUN_WORKSPACE: path.basename(workspaceRoot),
2669
2909
  ...(testRun ? testModeEnv() : {}),
2670
- ...sandboxSecrets,
2910
+ ...sandboxEnvSecrets,
2671
2911
  }).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
2672
2912
  ),
2673
2913
  emit: pushWatching,
@@ -2950,6 +3190,13 @@ async function runStep(
2950
3190
  // On this machine nobody can write in and there is no proxy: the
2951
3191
  // question goes to the terminal when there is one, else unanswered.
2952
3192
  const askTools = ask ? buildAskTool({ channel: null, timeoutSec: ask.timeoutSec, emit: push, local: terminalAsk }) : null;
3193
+ // Colleagues in the same group reach each other over the run's own
3194
+ // bus here — no proxy, so no person can write in, but a sibling can.
3195
+ // Every grouped step gets the inbox (a pod reads the proxy's inbox
3196
+ // whatever its tools); only tools: [peers] gets the tools to send.
3197
+ const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
3198
+ const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
3199
+ const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
2953
3200
  // @file secrets become 0600 paths here (host run), cleaned up in the
2954
3201
  // finally — once, however many attempts follow.
2955
3202
  const mat = materializeFileSecrets(agentDir, stepSecrets);
@@ -2973,6 +3220,9 @@ async function runStep(
2973
3220
  workspaceRoot,
2974
3221
  libraryRoot: libraryDir(tenant),
2975
3222
  ...(subagents.length ? { subagents } : {}),
3223
+ // The colleagues' inbox, read after every tool call — the same hook
3224
+ // a pod uses for a person's messages.
3225
+ ...(localInbox ? { inbox: localInbox } : {}),
2976
3226
  prompt,
2977
3227
  model,
2978
3228
  effort,
@@ -2987,6 +3237,7 @@ async function runStep(
2987
3237
  ...(historyTools.server ? { foldrun_history: historyTools.server } : {}),
2988
3238
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
2989
3239
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
3240
+ ...(peerTools ? { foldrun_peers: peerTools.server } : {}),
2990
3241
  ...(installTools ? { foldrun_install: installTools.server } : {}),
2991
3242
  ...mcpServers,
2992
3243
  },
@@ -3004,6 +3255,7 @@ async function runStep(
3004
3255
  output: step.output,
3005
3256
  schema: resolvedSchema ?? undefined,
3006
3257
  maxTurns: step.maxTurns,
3258
+ capture,
3007
3259
  ...limitOpts,
3008
3260
  ...hookOpts,
3009
3261
  stopRequested,
@@ -3266,7 +3518,7 @@ const CREDENTIAL_POLL_MS = 2_000;
3266
3518
  * step started one second before the 09:07:52Z refresh) matched none of the
3267
3519
  * older two, so the step failed instead of waiting for the new token. */
3268
3520
  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);
3521
+ return AUTH_REFUSAL.test(text);
3270
3522
  }
3271
3523
 
3272
3524
  /**
@@ -3890,6 +4142,12 @@ function driveRunInner(
3890
4142
  try {
3891
4143
  // Entering the loop is what makes a run live, whatever state it was
3892
4144
  // saved in: `queued` from the queue, `awaiting-approval` from a park.
4145
+ // The first pick-up (FOL-54 step 4): startedAt to this is the time it
4146
+ // queued; and the files it runs are the workspace's latest revision
4147
+ // as it starts — a deploy's revision is its commit, and an edit through
4148
+ // /source makes one too. A re-run comes through here as well.
4149
+ run.drivenAt ??= new Date().toISOString();
4150
+ if (run.revision === undefined) run.revision = latestRevisionId(tenant, workspace);
3893
4151
  if (run.status !== "running") {
3894
4152
  run.status = "running";
3895
4153
  run.parkedAt = null;
@@ -4466,6 +4724,13 @@ function driveRunInner(
4466
4724
  // twenty steps over — the shares sum to what was left. Between
4467
4725
  // groups the check above still refuses to start the next one.
4468
4726
  const launching = freshGroup.filter((s) => s.status === "pending");
4727
+ // tools: [peers] — what each step of the group is told about the
4728
+ // others, and the bus they share when they run in this process.
4729
+ const peerBus = localPeerBus();
4730
+ const siblingsOf = (step: StepRecord): PeerInfo[] =>
4731
+ launching
4732
+ .filter((s) => s !== step)
4733
+ .map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
4469
4734
  // An agent's own `budget:` — the most it may spend in one run — is
4470
4735
  // read at launch, so a cap raised mid-run applies to the next step.
4471
4736
  const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
@@ -4502,15 +4767,48 @@ function driveRunInner(
4502
4767
  if (!pool) pools.set(key, (pool = slotPool(step.parallel)));
4503
4768
  return pool;
4504
4769
  };
4770
+ // One instance of each agent goes first so its siblings read the
4771
+ // prompt cache it wrote — see warmGate. The claim is the callback's
4772
+ // first statement, before any await; the wait comes before the
4773
+ // pool, so nothing holds a slot while it waits.
4774
+ const gate = warmGate(launching);
4505
4775
  await Promise.all(
4506
4776
  launching
4507
4777
  .map(async (step) => {
4778
+ const lead = gate.claim(step);
4779
+ if (lead) {
4780
+ 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` });
4781
+ save();
4782
+ await lead;
4783
+ }
4508
4784
  const pool = poolFor(step);
4509
4785
  if (pool) await pool.acquire();
4510
4786
  try {
4511
- await launchStep(step);
4787
+ // A stop that arrived while this copy waited — for the
4788
+ // lead's reply or for a slot — must not start it: the
4789
+ // between-groups check runs only once this group is over,
4790
+ // and save() is what carries the flag in from the record.
4791
+ if (lead || pool) {
4792
+ save();
4793
+ if (run.stopRequested) {
4794
+ step.status = "skipped";
4795
+ step.skipReason = "run stopped";
4796
+ step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
4797
+ save();
4798
+ gate.release(step);
4799
+ return;
4800
+ }
4801
+ }
4802
+ await gate.run(step, () => launchStep(step));
4512
4803
  } finally {
4513
4804
  pool?.release();
4805
+ // Every attempt is over: off its siblings' rosters, so a
4806
+ // message to it is not reported delivered and never read.
4807
+ // Here, not per attempt — a step between retries is still
4808
+ // one its colleagues can reach.
4809
+ const index = run.steps.indexOf(step);
4810
+ peerBus.leave(index);
4811
+ await platform.egress.stepEnded?.({ tenant, runId: run.id, step: index }).catch(() => undefined);
4514
4812
  }
4515
4813
  }),
4516
4814
  );
@@ -4574,6 +4872,8 @@ function driveRunInner(
4574
4872
  },
4575
4873
  flowTimezone,
4576
4874
  run.steps.indexOf(step),
4875
+ siblingsOf(step),
4876
+ peerBus,
4577
4877
  ).finally(() => reserved.delete(step));
4578
4878
  // runStep mutates step.status; read it through a widened local
4579
4879
  // so TS doesn't keep the "running" narrowing from above.
@@ -5073,7 +5373,14 @@ export interface Reconciliation {
5073
5373
  * outlives any process. A run whose person already answered is not waiting,
5074
5374
  * and is picked back up — see below.
5075
5375
  */
5076
- export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlySet<string>): Reconciliation[] {
5376
+ /** Told when a run reconcile closed, or resumed and saw to its end — so the
5377
+ * platform settles, notifies and chains it as it would a run its worker
5378
+ * drove (a run closed here had spent tokens, and nobody was charged or
5379
+ * told). Not told when a resumed run parks again: whatever resumes it next
5380
+ * goes through the worker. */
5381
+ export type ReconcileEnd = (tenant: string, workspace: string, runId: string) => void | Promise<void>;
5382
+
5383
+ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[] {
5077
5384
  const closed: Reconciliation[] = [];
5078
5385
 
5079
5386
  for (const workspace of listWorkspaces(tenant)) {
@@ -5108,7 +5415,12 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
5108
5415
  // Park at any *later* gate rather than poll: reconcile runs inside
5109
5416
  // long-lived server processes, where a 24h file-poll is exactly the
5110
5417
  // slot-burning the queue exists to avoid.
5111
- void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true });
5418
+ void driveRun(tenant, workspace.name, run, null, run.tags ?? [], { parkOnApproval: true })
5419
+ .then(() => {
5420
+ const after = readRun(tenant, workspace.name, run.id);
5421
+ if (onEnd && after && (after.status === "completed" || after.status === "failed")) return onEnd(tenant, workspace.name, run.id);
5422
+ })
5423
+ .catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after resume:`, err));
5112
5424
  closed.push({
5113
5425
  runId: run.id,
5114
5426
  tenant,
@@ -5153,6 +5465,11 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
5153
5465
  // a sandbox we cannot reach must not stop the reconcile
5154
5466
  }
5155
5467
  closed.push({ runId: run.id, tenant, workspace: workspace.name, action: "closed", interrupted });
5468
+ if (onEnd) {
5469
+ void Promise.resolve()
5470
+ .then(() => onEnd(tenant, workspace.name, run.id))
5471
+ .catch((err) => console.error(`[foldrun] reconcile: ${tenant}/${workspace.name}/${run.id} after close:`, err));
5472
+ }
5156
5473
  }
5157
5474
  }
5158
5475
 
@@ -5166,8 +5483,8 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
5166
5483
  * one hard-coded account left every other account's interrupted runs marked
5167
5484
  * `running` forever, with no process left to finish them.
5168
5485
  */
5169
- export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string>): Reconciliation[] {
5170
- return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live));
5486
+ export function reconcileAllRuns(now = Date.now(), live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[] {
5487
+ return listTenants().flatMap((tenant) => reconcileRuns(tenant, now, live, onEnd));
5171
5488
  }
5172
5489
 
5173
5490
  /**