@foldrun/core 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +2 -2
  4. package/dist/src/agent-tools.js +2 -2
  5. package/dist/src/agents-md.d.ts +5 -1
  6. package/dist/src/agents-md.js +42 -18
  7. package/dist/src/alerts.d.ts +60 -0
  8. package/dist/src/alerts.js +161 -0
  9. package/dist/src/browser-pod.js +4 -0
  10. package/dist/src/capture.d.ts +23 -0
  11. package/dist/src/capture.js +80 -0
  12. package/dist/src/compare.d.ts +57 -0
  13. package/dist/src/compare.js +116 -0
  14. package/dist/src/completions.js +26 -3
  15. package/dist/src/deploy.d.ts +36 -1
  16. package/dist/src/deploy.js +237 -13
  17. package/dist/src/egress.d.ts +16 -1
  18. package/dist/src/evals.d.ts +27 -2
  19. package/dist/src/evals.js +62 -19
  20. package/dist/src/flow-lint.d.ts +14 -0
  21. package/dist/src/flow-lint.js +35 -0
  22. package/dist/src/flow-patterns.js +29 -14
  23. package/dist/src/frontmatter-fields.d.ts +27 -0
  24. package/dist/src/frontmatter-fields.js +60 -3
  25. package/dist/src/frontmatter-guard.d.ts +19 -0
  26. package/dist/src/frontmatter-guard.js +67 -0
  27. package/dist/src/frontmatter-schema.d.ts +2 -2
  28. package/dist/src/frontmatter-schema.js +33 -2
  29. package/dist/src/frontmatter-text.d.ts +31 -0
  30. package/dist/src/frontmatter-text.js +102 -0
  31. package/dist/src/history.d.ts +4 -0
  32. package/dist/src/history.js +15 -0
  33. package/dist/src/hooks.js +1 -1
  34. package/dist/src/insights.d.ts +30 -0
  35. package/dist/src/insights.js +152 -0
  36. package/dist/src/issues.d.ts +140 -0
  37. package/dist/src/issues.js +302 -0
  38. package/dist/src/judge-verdict.d.ts +19 -0
  39. package/dist/src/judge-verdict.js +54 -0
  40. package/dist/src/library.js +1 -1
  41. package/dist/src/limits.d.ts +8 -0
  42. package/dist/src/limits.js +36 -5
  43. package/dist/src/linkable.js +1 -1
  44. package/dist/src/matter.d.ts +42 -0
  45. package/dist/src/matter.js +132 -0
  46. package/dist/src/model-credential.d.ts +14 -0
  47. package/dist/src/model-credential.js +40 -0
  48. package/dist/src/network.d.ts +12 -1
  49. package/dist/src/network.js +35 -8
  50. package/dist/src/notify.d.ts +16 -0
  51. package/dist/src/notify.js +17 -7
  52. package/dist/src/okf.js +1 -1
  53. package/dist/src/online-checks.d.ts +76 -0
  54. package/dist/src/online-checks.js +432 -0
  55. package/dist/src/operator.d.ts +277 -0
  56. package/dist/src/operator.js +176 -2
  57. package/dist/src/otel-config.d.ts +18 -0
  58. package/dist/src/otel-config.js +69 -0
  59. package/dist/src/otel.d.ts +38 -0
  60. package/dist/src/otel.js +85 -0
  61. package/dist/src/outcome.d.ts +40 -0
  62. package/dist/src/outcome.js +77 -0
  63. package/dist/src/package.js +9 -2
  64. package/dist/src/platform.d.ts +1 -1
  65. package/dist/src/prompt-layers.d.ts +30 -0
  66. package/dist/src/prompt-layers.js +77 -0
  67. package/dist/src/provider-health.js +2 -2
  68. package/dist/src/providers.d.ts +5 -0
  69. package/dist/src/providers.js +2 -2
  70. package/dist/src/run-container.d.ts +25 -2
  71. package/dist/src/run-container.js +61 -6
  72. package/dist/src/runner.d.ts +53 -2
  73. package/dist/src/runner.js +471 -102
  74. package/dist/src/runs-filter.d.ts +48 -0
  75. package/dist/src/runs-filter.js +73 -0
  76. package/dist/src/scan.d.ts +30 -1
  77. package/dist/src/scan.js +145 -14
  78. package/dist/src/starter.js +27 -2
  79. package/dist/src/step-exec.d.ts +39 -3
  80. package/dist/src/step-exec.js +118 -15
  81. package/dist/src/store.d.ts +144 -3
  82. package/dist/src/store.js +150 -7
  83. package/dist/src/tokens.d.ts +38 -0
  84. package/dist/src/tokens.js +57 -0
  85. package/dist/src/tool-names.js +1 -1
  86. package/dist/src/tool-programs.js +1 -1
  87. package/dist/src/trace-tree.d.ts +40 -0
  88. package/dist/src/trace-tree.js +156 -0
  89. package/dist/src/translator.d.ts +8 -0
  90. package/dist/src/translator.js +7 -2
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/workspace-check.d.ts +18 -0
  93. package/dist/src/workspace-check.js +34 -0
  94. package/package.json +2 -2
  95. package/src/agent-activity.ts +32 -0
  96. package/src/agent-import.ts +2 -2
  97. package/src/agent-tools.ts +2 -2
  98. package/src/agents-md.ts +39 -14
  99. package/src/alerts.ts +190 -0
  100. package/src/browser-pod.ts +3 -0
  101. package/src/capture.ts +88 -0
  102. package/src/compare.ts +146 -0
  103. package/src/completions.ts +27 -3
  104. package/src/deploy.ts +238 -12
  105. package/src/egress.ts +12 -2
  106. package/src/evals.ts +64 -19
  107. package/src/flow-lint.ts +36 -0
  108. package/src/flow-patterns.ts +26 -13
  109. package/src/frontmatter-fields.ts +83 -3
  110. package/src/frontmatter-guard.ts +70 -0
  111. package/src/frontmatter-schema.ts +33 -4
  112. package/src/frontmatter-text.ts +109 -0
  113. package/src/history.ts +13 -0
  114. package/src/hooks.ts +1 -1
  115. package/src/insights.ts +174 -0
  116. package/src/issues.ts +396 -0
  117. package/src/judge-verdict.ts +52 -0
  118. package/src/library.ts +1 -1
  119. package/src/limits.ts +38 -5
  120. package/src/linkable.ts +1 -1
  121. package/src/matter.ts +145 -0
  122. package/src/model-credential.ts +45 -0
  123. package/src/network.ts +35 -7
  124. package/src/notify.ts +22 -7
  125. package/src/okf.ts +1 -1
  126. package/src/online-checks.ts +450 -0
  127. package/src/operator.ts +213 -3
  128. package/src/otel-config.ts +67 -0
  129. package/src/otel.ts +100 -0
  130. package/src/outcome.ts +90 -0
  131. package/src/package.ts +8 -2
  132. package/src/platform.ts +1 -1
  133. package/src/prompt-layers.ts +90 -0
  134. package/src/provider-health.ts +2 -2
  135. package/src/providers.ts +7 -2
  136. package/src/run-container.ts +77 -8
  137. package/src/runner.ts +477 -79
  138. package/src/runs-filter.ts +88 -0
  139. package/src/scan.ts +153 -14
  140. package/src/starter.ts +27 -2
  141. package/src/step-exec.ts +168 -17
  142. package/src/store.ts +268 -9
  143. package/src/tokens.ts +68 -0
  144. package/src/tool-names.ts +1 -1
  145. package/src/tool-programs.ts +1 -1
  146. package/src/trace-tree.ts +187 -0
  147. package/src/translator.ts +13 -2
  148. package/src/trigger-log.ts +1 -0
  149. package/src/workspace-check.ts +41 -0
@@ -0,0 +1,85 @@
1
+ // A run as an OpenTelemetry trace — OTLP/HTTP JSON, the body a collector's
2
+ // /v1/traces takes — so an account's agent runs sit in the tracing it
3
+ // already has (Grafana Tempo, Honeycomb, Datadog, Jaeger, Langfuse…) beside
4
+ // the services they act on. Built from buildTraceTree, the same tree the run
5
+ // page draws: run → step → model call → tool call.
6
+ //
7
+ // telemetry:
8
+ // otlp: https://otel.example.com/v1/traces
9
+ // headers:
10
+ // authorization: ${OTEL_TOKEN} # a vault secret, by name
11
+ //
12
+ // In AGENTS.md (the workspace's, else the account's — the block replaces
13
+ // whole, like notify:). Ids are derived from the run and span ids, so a run
14
+ // sent twice is the same trace, not two. No OpenTelemetry SDK: the wire
15
+ // format is small, and a dependency in every runner image is not.
16
+ import crypto from "node:crypto";
17
+ import { buildTraceTree } from "./trace-tree.js";
18
+ export { parseTelemetry, telemetrySecretNames, telemetryHeaders } from "./otel-config.js";
19
+ const attr = (key, v) => {
20
+ if (v === null || v === undefined || v === "")
21
+ return [];
22
+ if (typeof v === "boolean")
23
+ return [{ key, value: { boolValue: v } }];
24
+ if (typeof v === "number")
25
+ return [{ key, value: Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v } }];
26
+ return [{ key, value: { stringValue: v } }];
27
+ };
28
+ const hex = (s, len) => crypto.createHash("sha256").update(s).digest("hex").slice(0, len);
29
+ const nanos = (msEpoch) => (BigInt(Math.round(msEpoch)) * BigInt(1_000_000)).toString();
30
+ /** OTLP span kinds: INTERNAL, and CLIENT for a call that leaves the platform. */
31
+ const SPAN_KIND = { internal: 1, client: 3 };
32
+ /** The ExportTraceServiceRequest for one run. */
33
+ export function runToOtlp(run, ctx, now = Date.now()) {
34
+ const tree = buildTraceTree(run, now);
35
+ const traceId = hex(`foldrun-trace\0${ctx.tenant}\0${ctx.workspace}\0${run.id}`, 32);
36
+ const spans = [];
37
+ const visit = (s, parent, inherited) => {
38
+ const spanId = hex(`${traceId}\0${s.id}`, 16);
39
+ const start = s.start ?? inherited.start;
40
+ const end = Math.max(start, s.end ?? s.start ?? now);
41
+ const step = s.step !== undefined ? run.steps[s.step] : undefined;
42
+ const agent = s.kind === "step" ? step?.agent : inherited.agent;
43
+ spans.push({
44
+ traceId,
45
+ spanId,
46
+ ...(parent ? { parentSpanId: parent } : {}),
47
+ name: s.kind === "run" ? `run ${s.label}` : s.kind === "step" ? `step ${s.label}` : s.kind === "model" ? `chat ${s.model ?? s.label}` : s.kind === "tool" ? `tool ${s.label}` : s.label,
48
+ kind: s.kind === "model" ? SPAN_KIND.client : SPAN_KIND.internal,
49
+ startTimeUnixNano: nanos(start),
50
+ endTimeUnixNano: nanos(end),
51
+ attributes: [
52
+ ...attr("foldrun.span.kind", s.kind),
53
+ ...attr("foldrun.status", s.status),
54
+ ...(s.kind === "step" ? [...attr("foldrun.agent", agent), ...attr("foldrun.owner", agent ? ctx.owners?.[agent] ?? null : null), ...attr("foldrun.step", s.step)] : []),
55
+ ...attr("gen_ai.request.model", s.model),
56
+ ...(s.tokens ? [...attr("gen_ai.usage.input_tokens", s.tokens.input), ...attr("gen_ai.usage.output_tokens", s.tokens.output), ...attr("gen_ai.usage.cache_read_tokens", s.tokens.cacheRead), ...attr("gen_ai.usage.cache_write_tokens", s.tokens.cacheWrite)] : []),
57
+ ...attr("gen_ai.response.finish_reason", s.stop),
58
+ ...(typeof s.costUsd === "number" ? attr("foldrun.cost_usd", s.costUsd) : []),
59
+ ...(s.kind === "tool" ? [...attr("gen_ai.tool.name", s.label), ...attr("gen_ai.tool.call.id", s.call)] : []),
60
+ ...attr("foldrun.subagent", s.subagent),
61
+ ],
62
+ status: s.status === "error" ? { code: 2, ...(s.text ? { message: s.text.slice(0, 500) } : {}) } : { code: 0 },
63
+ });
64
+ for (const c of s.children)
65
+ visit(c, spanId, { start, agent });
66
+ };
67
+ visit(tree, null, { start: Date.parse(run.startedAt) || now });
68
+ return {
69
+ resourceSpans: [
70
+ {
71
+ resource: {
72
+ attributes: [
73
+ ...attr("service.name", "foldrun"),
74
+ ...attr("foldrun.account", ctx.tenant),
75
+ ...attr("foldrun.workspace", ctx.workspace),
76
+ ...attr("foldrun.flow", run.flow),
77
+ ...attr("foldrun.run_id", run.id),
78
+ ...attr("foldrun.started_by", run.startedBy ?? null),
79
+ ],
80
+ },
81
+ scopeSpans: [{ scope: { name: "foldrun", ...(ctx.version ? { version: ctx.version } : {}) }, spans }],
82
+ },
83
+ ],
84
+ };
85
+ }
@@ -0,0 +1,40 @@
1
+ export type Outcome = "running" | "queued" | "waiting" | "good" | "done" | "quiet" | "blocked" | "bad" | "failed" | "stopped";
2
+ export declare const OUTCOMES: readonly Outcome[];
3
+ /** From the display status (store.runDisplayStatus) and the verdict (store.runVerdict). */
4
+ export declare function outcomeOf(status: string, verdict: string | null | undefined): Outcome;
5
+ /** Still going: not an outcome yet. */
6
+ export declare const isLive: (o: Outcome) => o is "queued" | "running" | "waiting";
7
+ /** Ran to the end without failing — whatever it found. */
8
+ export declare const isCompleted: (o: Outcome) => o is "bad" | "blocked" | "done" | "good" | "quiet";
9
+ /** Over and countable for a rate: completed or failed. A run a person stopped is left out. */
10
+ export declare const isFinished: (o: Outcome) => o is "bad" | "blocked" | "done" | "failed" | "good" | "quiet";
11
+ /** Went wrong or found something wrong: failed, BAD, BLOCKED. */
12
+ export declare const needsAttention: (o: Outcome) => o is "bad" | "blocked" | "failed";
13
+ /** A person has something to do: what needsAttention says, and a run
14
+ * waiting on an approval or an answer (Codex review). */
15
+ export declare const needsAction: (o: Outcome) => o is "bad" | "blocked" | "failed" | "waiting";
16
+ /** Completed and reported nothing wrong (GOOD, QUIET, or nothing said). */
17
+ export declare const isSuccess: (o: Outcome) => o is "done" | "good" | "quiet";
18
+ export interface Tally {
19
+ /** Every run counted, live ones included. */
20
+ runs: number;
21
+ /** Running, queued, or waiting on a person. */
22
+ live: number;
23
+ /** Waiting on an approval or an answer. */
24
+ waiting: number;
25
+ stopped: number;
26
+ /** Completed + failed: the runs a rate is taken over. */
27
+ finished: number;
28
+ /** Ran to the end without failing. */
29
+ completed: number;
30
+ failed: number;
31
+ /** What the completed runs found. */
32
+ ok: number;
33
+ quiet: number;
34
+ blocked: number;
35
+ bad: number;
36
+ /** completed / finished, as a whole percent; null with nothing finished. */
37
+ completedPct: number | null;
38
+ }
39
+ /** The one count of a set of outcomes that every surface shows. */
40
+ export declare function tally(outcomes: Iterable<Outcome>): Tally;
@@ -0,0 +1,77 @@
1
+ // A run's outcome, and the one way every surface counts outcomes (FOL-54).
2
+ //
3
+ // A run is read on three separate lines, never folded into one "success":
4
+ // · execution — did it run to the end without failing? (completed, failed,
5
+ // stopped by a person, or still live)
6
+ // · result — what the work concluded: GOOD, QUIET, BLOCKED, BAD, or
7
+ // nothing said (done)
8
+ // · action — does a person need to do something? (an approval or a
9
+ // question waiting, a failure, a BLOCKED, a BAD finding)
10
+ //
11
+ // Before this, five surfaces computed five different "success rates" (one
12
+ // counted BAD as success, one counted stopped runs as failures, …), so the
13
+ // same desk read four different percentages. Every page, the API and the
14
+ // CLI now read `tally()`. Pure: no node imports, so the browser can use it.
15
+ export const OUTCOMES = ["running", "queued", "waiting", "good", "done", "quiet", "blocked", "bad", "failed", "stopped"];
16
+ /** From the display status (store.runDisplayStatus) and the verdict (store.runVerdict). */
17
+ export function outcomeOf(status, verdict) {
18
+ if (status === "running")
19
+ return "running";
20
+ if (status === "queued")
21
+ return "queued";
22
+ if (status === "awaiting-approval")
23
+ return "waiting";
24
+ if (status === "stopped")
25
+ return "stopped";
26
+ if (status === "failed")
27
+ return "failed";
28
+ switch (verdict) {
29
+ case "GOOD": return "good";
30
+ case "QUIET": return "quiet";
31
+ case "BLOCKED": return "blocked";
32
+ case "BAD": return "bad";
33
+ default: return "done";
34
+ }
35
+ }
36
+ /** Still going: not an outcome yet. */
37
+ export const isLive = (o) => o === "running" || o === "queued" || o === "waiting";
38
+ /** Ran to the end without failing — whatever it found. */
39
+ export const isCompleted = (o) => o === "good" || o === "done" || o === "quiet" || o === "blocked" || o === "bad";
40
+ /** Over and countable for a rate: completed or failed. A run a person stopped is left out. */
41
+ export const isFinished = (o) => isCompleted(o) || o === "failed";
42
+ /** Went wrong or found something wrong: failed, BAD, BLOCKED. */
43
+ export const needsAttention = (o) => o === "failed" || o === "bad" || o === "blocked";
44
+ /** A person has something to do: what needsAttention says, and a run
45
+ * waiting on an approval or an answer (Codex review). */
46
+ export const needsAction = (o) => needsAttention(o) || o === "waiting";
47
+ /** Completed and reported nothing wrong (GOOD, QUIET, or nothing said). */
48
+ export const isSuccess = (o) => o === "good" || o === "done" || o === "quiet";
49
+ /** The one count of a set of outcomes that every surface shows. */
50
+ export function tally(outcomes) {
51
+ const t = { runs: 0, live: 0, waiting: 0, stopped: 0, finished: 0, completed: 0, failed: 0, ok: 0, quiet: 0, blocked: 0, bad: 0, completedPct: null };
52
+ for (const o of outcomes) {
53
+ t.runs++;
54
+ if (isLive(o))
55
+ t.live++;
56
+ if (o === "waiting")
57
+ t.waiting++;
58
+ if (o === "stopped")
59
+ t.stopped++;
60
+ if (isFinished(o))
61
+ t.finished++;
62
+ if (isCompleted(o))
63
+ t.completed++;
64
+ if (o === "failed")
65
+ t.failed++;
66
+ if (o === "good" || o === "done")
67
+ t.ok++;
68
+ if (o === "quiet")
69
+ t.quiet++;
70
+ if (o === "blocked")
71
+ t.blocked++;
72
+ if (o === "bad")
73
+ t.bad++;
74
+ }
75
+ t.completedPct = t.finished ? Math.round((100 * t.completed) / t.finished) : null;
76
+ return t;
77
+ }
@@ -25,7 +25,7 @@
25
25
  // claim, the files are what will run.
26
26
  import fs from "node:fs";
27
27
  import path from "node:path";
28
- import matter from "gray-matter";
28
+ import matter, { configMatter } from "./matter.js";
29
29
  import { assertSafeName, isEditablePath, listWorkspaceFiles, listWorkspaces, listFlows, parseFlow, readWorkspaceFile, saveWorkspace, blankTemplateFiles, syncBundleFor, notifyWorkspaceChanged, workspaceDir, } from "./store.js";
30
30
  import { recordRevision } from "./history.js";
31
31
  import { libraryDir, libraryTools } from "./library.js";
@@ -522,7 +522,14 @@ function safeFront(content) {
522
522
  }
523
523
  }
524
524
  function frontOf(content) {
525
- const d = safeFront(content);
525
+ // An agent's secrets: and tools: may sit in its guardrail: block.
526
+ let d;
527
+ try {
528
+ d = configMatter(content).data;
529
+ }
530
+ catch {
531
+ d = {};
532
+ }
526
533
  const apis = Array.isArray(d.apis) ? d.apis.map((a) => (a && typeof a === "object" ? String(a.name ?? "") : "")) : [];
527
534
  const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
528
535
  const scripts = list(d.scripts);
@@ -14,7 +14,7 @@ export type TriggerAdmission = {
14
14
  admit: true;
15
15
  } | {
16
16
  admit: false;
17
- reason: "duplicate" | "throttled" | "quarantined" | "paused" | "debounced";
17
+ reason: "duplicate" | "throttled" | "quarantined" | "paused" | "halted" | "debounced";
18
18
  detail: string;
19
19
  firesAt?: number;
20
20
  };
@@ -0,0 +1,30 @@
1
+ import type { PromptLayerRecord } from "./tokens.ts";
2
+ export { promptLayersChanged, promptCacheNote, type PromptLayerRecord } from "./tokens.ts";
3
+ export interface AssembledPrompt {
4
+ /** The whole prompt, stable layers first. */
5
+ text: string;
6
+ layers: PromptLayerRecord[];
7
+ }
8
+ export declare function hashLayer(text: string): string;
9
+ /**
10
+ * A collector agentContext fills in whatever order it computes its
11
+ * sections. `add` with the same name twice appends to that layer, so a
12
+ * section built in two places (the platform's rules) is one layer.
13
+ */
14
+ export declare class PromptLayers {
15
+ private readonly order;
16
+ private readonly text;
17
+ private readonly dynamic;
18
+ add(name: string, text: string, opts?: {
19
+ dynamic?: boolean;
20
+ }): void;
21
+ assemble(): AssembledPrompt;
22
+ }
23
+ /**
24
+ * The key an OpenAI-format provider routes a prompt by (`prompt_cache_key`).
25
+ * One per agent: every step of an agent opens with the same prefix, and a
26
+ * key that names the account keeps one tenant's cache from being probed
27
+ * through another's requests. Hashed, since the names are a person's and
28
+ * the parameter is capped at 64 characters.
29
+ */
30
+ export declare function promptCacheKey(tenant: string, workspace: string, agent: string): string;
@@ -0,0 +1,77 @@
1
+ // The system prompt in layers, ordered for the model's prompt cache.
2
+ //
3
+ // Every provider caches a prompt by its prefix: a request whose opening
4
+ // bytes match one it has seen reads the model's work on them instead of
5
+ // paying for it again, and a byte that differs invalidates everything
6
+ // after it. agentContext used to join its sections in the order it
7
+ // computed them, which put the memory index, the state file list and the
8
+ // clock — the three things that change between runs — in the middle.
9
+ //
10
+ // This collector keeps the sections apart by how often they change and
11
+ // assembles them stable first, dynamic last: what Anthropic does for
12
+ // Claude Code ("static content first, dynamic content last"). The prompt
13
+ // still goes to the SDK as one string, cached as one block. The Agent
14
+ // SDK's SYSTEM_PROMPT_DYNAMIC_BOUNDARY would split it, but the CLI honours
15
+ // the marker only when it talks to api.anthropic.com directly — a step
16
+ // behind the egress proxy got one block anyway (live check, 20 Oct 2026;
17
+ // claude-code issue #102518) — and where it does split, it sends the
18
+ // stable half with the CLI's "global" cache scope, which is not a scope to
19
+ // put an account's own prose in. So no marker.
20
+ //
21
+ // Each layer is also hashed, and the hashes ride on the step record
22
+ // (`promptLayers`). A step whose cache writes are high can then say which
23
+ // layer changed since the agent's last step — "memory changed" — instead
24
+ // of leaving a person to diff two 40k-character prompts.
25
+ import crypto from "node:crypto";
26
+ // The record's shape and the readers' comparison live in tokens.ts, which
27
+ // has no node imports and is what the dashboard's client bundle reads.
28
+ export { promptLayersChanged, promptCacheNote } from "./tokens.js";
29
+ const SEP = "\n\n";
30
+ export function hashLayer(text) {
31
+ return crypto.createHash("sha256").update(text).digest("hex").slice(0, 12);
32
+ }
33
+ /**
34
+ * A collector agentContext fills in whatever order it computes its
35
+ * sections. `add` with the same name twice appends to that layer, so a
36
+ * section built in two places (the platform's rules) is one layer.
37
+ */
38
+ export class PromptLayers {
39
+ order = [];
40
+ text = new Map();
41
+ dynamic = new Set();
42
+ add(name, text, opts = {}) {
43
+ if (!text)
44
+ return;
45
+ if (!this.text.has(name)) {
46
+ this.order.push(name);
47
+ this.text.set(name, []);
48
+ }
49
+ this.text.get(name).push(text);
50
+ if (opts.dynamic)
51
+ this.dynamic.add(name);
52
+ }
53
+ assemble() {
54
+ const parts = [];
55
+ const layers = [];
56
+ const take = (names, isDynamic) => {
57
+ for (const name of names) {
58
+ const joined = this.text.get(name).join(SEP);
59
+ parts.push(joined);
60
+ layers.push({ name, chars: joined.length, hash: hashLayer(joined), ...(isDynamic ? { stable: false } : {}) });
61
+ }
62
+ };
63
+ take(this.order.filter((n) => !this.dynamic.has(n)), false);
64
+ take(this.order.filter((n) => this.dynamic.has(n)), true);
65
+ return { text: parts.join(SEP), layers };
66
+ }
67
+ }
68
+ /**
69
+ * The key an OpenAI-format provider routes a prompt by (`prompt_cache_key`).
70
+ * One per agent: every step of an agent opens with the same prefix, and a
71
+ * key that names the account keeps one tenant's cache from being probed
72
+ * through another's requests. Hashed, since the names are a person's and
73
+ * the parameter is capped at 64 characters.
74
+ */
75
+ export function promptCacheKey(tenant, workspace, agent) {
76
+ return `fr-${crypto.createHash("sha256").update(`${tenant}\u0000${workspace}\u0000${agent}`).digest("hex").slice(0, 32)}`;
77
+ }
@@ -20,7 +20,7 @@
20
20
  import { accountModelBlock } from "./model-credential.js";
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
- import matter from "gray-matter";
23
+ import { configMatter } from "./matter.js";
24
24
  import { getSecret } from "./secrets.js";
25
25
  import { noteSecretUse, healthKey } from "./secret-health.js";
26
26
  import { accountDir, workspaceDir, listWorkspaces, listAgents, parseProvider } from "./store.js";
@@ -168,7 +168,7 @@ function cheapestModel(spec) {
168
168
  }
169
169
  function frontmatterOf(file) {
170
170
  try {
171
- return (matter(fs.readFileSync(file, "utf8")).data ?? {});
171
+ return (configMatter(fs.readFileSync(file, "utf8")).data ?? {});
172
172
  }
173
173
  catch {
174
174
  return {};
@@ -20,6 +20,11 @@ export interface ProviderPreset {
20
20
  * Off means the translator drops thinking rather than send a parameter
21
21
  * the endpoint would reject. */
22
22
  reasoningEffort?: boolean;
23
+ /** Whether the endpoint takes `prompt_cache_key` — a hint that requests
24
+ * with the same key share a prompt prefix, so its cache can be routed
25
+ * to. Only set where the parameter is documented; an endpoint that does
26
+ * not know it may refuse the request. */
27
+ promptCacheKey?: boolean;
23
28
  /** One line a person should read before relying on it. */
24
29
  note?: string;
25
30
  /** A tool loop was driven through this endpoint from this runtime. */
@@ -222,7 +222,7 @@ export const PROVIDERS = [
222
222
  note: "base_url is your server (default :8000). Start it with --enable-auto-tool-choice and a --tool-call-parser or no tool loop closes." },
223
223
  // ------------------------------------------- Chat-Completions, translated
224
224
  { name: "openai", title: "OpenAI", format: "openai", baseUrl: "https://api.openai.com/v1", auth: "bearer", verified: true,
225
- maxTokensParam: "max_completion_tokens", reasoningEffort: true,
225
+ maxTokensParam: "max_completion_tokens", reasoningEffort: true, promptCacheKey: true,
226
226
  note: "Reached over Chat Completions, which OpenAI keeps supporting; the Responses API is not spoken here, so a reasoning model's reasoning does not carry across tool calls.", search: "openai" },
227
227
  { name: "gemini", title: "Google Gemini", format: "openai", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", auth: "bearer",
228
228
  note: "Google's OpenAI-compatible route. Unknown parameters are ignored silently; reasoning cannot be switched off on the newest models." },
@@ -230,7 +230,7 @@ export const PROVIDERS = [
230
230
  maxTokensParam: "max_completion_tokens", reasoningEffort: true },
231
231
  { name: "groq", title: "Groq", format: "openai", baseUrl: "https://api.groq.com/openai/v1", auth: "bearer",
232
232
  maxTokensParam: "max_completion_tokens", reasoningEffort: true },
233
- { name: "mistral", title: "Mistral", format: "openai", baseUrl: "https://api.mistral.ai/v1", auth: "bearer" },
233
+ { name: "mistral", title: "Mistral", format: "openai", baseUrl: "https://api.mistral.ai/v1", auth: "bearer", promptCacheKey: true },
234
234
  { name: "together", title: "Together AI", format: "openai", baseUrl: "https://api.together.ai/v1", auth: "bearer" },
235
235
  { name: "cerebras", title: "Cerebras", format: "openai", baseUrl: "https://api.cerebras.ai/v1", auth: "bearer",
236
236
  maxTokensParam: "max_completion_tokens", reasoningEffort: true },
@@ -1,7 +1,8 @@
1
+ import type { PeerInfo } from "./operator.ts";
1
2
  import { type HookRecord } from "./hooks.ts";
2
3
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
3
- import { type ApiSpec, type Effort } from "./store.ts";
4
- import type { EventExtra, ExecUsage } from "./step-exec.ts";
4
+ import { type ApiSpec, type Effort, type ModelCall } from "./store.ts";
5
+ import type { CallIo, EventExtra, ExecUsage } from "./step-exec.ts";
5
6
  import type { TokenPrice } from "./catalog.ts";
6
7
  import { type RuntimeSpec } from "./runtime.ts";
7
8
  import { type NetworkLevel } from "./network.ts";
@@ -44,11 +45,16 @@ export interface ContainerStepInput {
44
45
  budgetNote?: string;
45
46
  price?: TokenPrice | null;
46
47
  verify?: string;
48
+ /** The model `verify: judge:` grades with (the workspace's `judge:`). */
49
+ judgeModel?: string;
50
+ judgeEffort?: string;
47
51
  /** `output: json` — checked inside, where the reply is. */
48
52
  output?: "json";
49
53
  /** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
50
54
  schema?: Record<string, unknown> | boolean;
51
55
  maxTurns?: number;
56
+ /** `capture:` resolved host-side (FOL-54 step 4) — see ExecOptions. */
57
+ capture?: "metadata" | "full";
52
58
  /** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
53
59
  * count reads (limits.ts) — enforced in the pod by the same hook. */
54
60
  limits?: Record<string, number>;
@@ -67,6 +73,13 @@ export interface ContainerStepInput {
67
73
  ask?: {
68
74
  timeoutSec: number;
69
75
  };
76
+ /** tools: [peers] — this step's agent and the other steps of its
77
+ * parallel group, gathered host-side at launch. The driver rebuilds the
78
+ * colleagues' tools in here over the lease's /peers door. */
79
+ peers?: {
80
+ self: string;
81
+ siblings: PeerInfo[];
82
+ };
70
83
  /** A Chat-Completions provider: the driver starts the runtime's translator
71
84
  * on loopback and points the SDK at it. Null or absent: the env's
72
85
  * ANTHROPIC_BASE_URL is spoken to directly. */
@@ -275,6 +288,8 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
275
288
  source: string;
276
289
  target: string;
277
290
  } | null;
291
+ /** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
292
+ export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
278
293
  /** The uv release copied into the image. Pinned: a floating uv is a floating
279
294
  * resolver, and the runtime cache is keyed on the declaration, not on it. */
280
295
  export declare const UV_VERSION = "0.11.25";
@@ -374,6 +389,10 @@ export declare function parseDriverLine(line: string): {
374
389
  subagent?: string;
375
390
  check?: boolean;
376
391
  hook?: HookRecord;
392
+ io?: CallIo;
393
+ turn?: ModelCall;
394
+ inCall?: string;
395
+ responding?: true;
377
396
  } | ({
378
397
  e: "done";
379
398
  } & ContainerStepOutcome) | null;
@@ -401,6 +420,10 @@ export interface RunInContainerArgs {
401
420
  * without it, "stop" could only mean "stop after this step", and a
402
421
  * browser step has fifteen minutes left to spend. */
403
422
  runId?: string;
423
+ /** Whether a person has stopped the run, read from the record. A step that
424
+ * waits for room before its sandbox exists asks this, since there is no
425
+ * sandbox yet for the stop to destroy. */
426
+ stopRequested?: () => boolean;
404
427
  /** The reservation class — which limits this step's sandbox holds. */
405
428
  size?: "small" | "large" | "heavy";
406
429
  /** Re-attach to a sandbox a previous driver started, instead of creating
@@ -23,7 +23,7 @@ import os from "node:os";
23
23
  import path from "node:path";
24
24
  import crypto from "node:crypto";
25
25
  import { spawn, spawnSync } from "node:child_process";
26
- import { isPlatformPath } from "./store.js";
26
+ import { CAPTURE_CAP, isPlatformPath } from "./store.js";
27
27
  import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.js";
28
28
  import { isFileValue, fileContent } from "./secrets.js";
29
29
  import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET } from "./runtime.js";
@@ -410,7 +410,8 @@ export function runtimeCacheMount(tenant) {
410
410
  return { source, target: RUNTIME_CACHE };
411
411
  }
412
412
  // ---------- the runner image ----------
413
- const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
413
+ /** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
414
+ export const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
414
415
  // same core the server uses, speaks JSONL on stdout, exits.
415
416
  import fs from "node:fs";
416
417
  const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
@@ -475,7 +476,7 @@ try {
475
476
  const { buildScriptTools } = await import("@foldrun/core/script-tools");
476
477
  const { buildConsultTools } = await import("@foldrun/core/agent-tools");
477
478
  const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
478
- const { httpChannel, buildAskTool, inboxReader } = await import("@foldrun/core/operator");
479
+ const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import("@foldrun/core/operator");
479
480
  const { startTranslator } = await import("@foldrun/core/translator");
480
481
  const { prepareRuntime } = await import("@foldrun/core/runtime");
481
482
  const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
@@ -539,11 +540,18 @@ try {
539
540
  // A long result is kept in a file only when the agent can read it back.
540
541
  // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)
541
542
  const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === "Read" || t === "Grep") };
542
- const api = buildApiTools("", input.apis, undefined, { env, missing: [] }, keepIn);
543
+ // A declared secret named like a model variable crosses as
544
+ // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own
545
+ // settings, and the agent's API and script tools and verify: get the value
546
+ // back under the name it declared. Built here, after the runtime and any
547
+ // mid-step install have added their paths to env.
548
+ const toolEnv = { ...env };
549
+ for (const [k, v] of Object.entries(env)) if (k.startsWith("FOLDRUN_DECLARED_")) toolEnv[k.slice("FOLDRUN_DECLARED_".length)] = v;
550
+ const api = buildApiTools("", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);
543
551
  const script = buildScriptTools(
544
552
  agentDir,
545
553
  input.scripts,
546
- { ...env, ...runtime.env },
554
+ toolEnv,
547
555
  "/library/scripts",
548
556
  runtime.interpreters,
549
557
  null,
@@ -566,6 +574,7 @@ try {
566
574
  // ask_person when tools: [ask], and the run's inbox after every tool call.
567
575
  const channel = httpChannel(env.FOLDRUN_EGRESS);
568
576
  const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
577
+ const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
569
578
  const inbox = channel ? inboxReader(channel, emit) : undefined;
570
579
 
571
580
  mark("tools");
@@ -587,6 +596,7 @@ try {
587
596
  ...(history.server ? { foldrun_history: history.server } : {}),
588
597
  ...(desks.server ? { foldrun_desks: desks.server } : {}),
589
598
  ...(ask.server ? { foldrun_ask: ask.server } : {}),
599
+ ...(peers.server ? { foldrun_peers: peers.server } : {}),
590
600
  ...(install.server ? { foldrun_install: install.server } : {}),
591
601
  ...input.mcpServers,
592
602
  },
@@ -598,10 +608,13 @@ try {
598
608
  budgetNote: input.budgetNote,
599
609
  price: input.price,
600
610
  verify: input.verify,
601
- verifyEnv: env,
611
+ verifyEnv: toolEnv,
612
+ ...(typeof input.judgeModel === "string" ? { judgeModel: input.judgeModel } : {}),
613
+ ...(typeof input.judgeEffort === "string" ? { judgeEffort: input.judgeEffort } : {}),
602
614
  output: input.output,
603
615
  schema: input.schema,
604
616
  maxTurns: input.maxTurns,
617
+ ...(input.capture === "metadata" || input.capture === "full" ? { capture: input.capture } : {}),
605
618
  ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
606
619
  ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),
607
620
  // Slim browsing: the platform put this step on the slim image to browse
@@ -1040,6 +1053,44 @@ export function buildFailureSummary(log, opts = {}) {
1040
1053
  return `${head}\n--- the last ${tail} characters of the build log ---\n${log.slice(-tail)}`;
1041
1054
  }
1042
1055
  // ---------- running one step ----------
1056
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
1057
+ /** A tool call's content from the sandbox, as far as it is well-formed and
1058
+ * within the cap; anything else is dropped (FOL-54 step 4). */
1059
+ function callIoOf(v) {
1060
+ if (!v || typeof v !== "object")
1061
+ return null;
1062
+ const o = v;
1063
+ // The cap plus the slack the runner masks inside before it cuts.
1064
+ const text = (x) => (typeof x === "string" ? x.slice(0, CAPTURE_CAP + 4096) : undefined);
1065
+ const io = {
1066
+ ...(text(o.input) !== undefined ? { input: text(o.input) } : {}),
1067
+ ...(text(o.output) !== undefined ? { output: text(o.output) } : {}),
1068
+ ...(typeof o.inputChars === "number" ? { inputChars: num(o.inputChars) } : {}),
1069
+ ...(typeof o.outputChars === "number" ? { outputChars: num(o.outputChars) } : {}),
1070
+ ...(o.truncated === true ? { truncated: true } : {}),
1071
+ };
1072
+ return io.input !== undefined || io.output !== undefined ? io : null;
1073
+ }
1074
+ /** One model call from the sandbox, well-formed or dropped. */
1075
+ function modelCallOf(v) {
1076
+ if (!v || typeof v !== "object")
1077
+ return null;
1078
+ const o = v;
1079
+ const t = (o.tokens ?? {});
1080
+ if (typeof o.id !== "string" || !o.id || o.id.length > 128 || typeof o.at !== "string" || Number.isNaN(Date.parse(o.at)))
1081
+ return null;
1082
+ return {
1083
+ id: o.id,
1084
+ n: Math.max(1, Math.floor(num(o.n))),
1085
+ at: o.at,
1086
+ ms: num(o.ms),
1087
+ ...(typeof o.model === "string" ? { model: o.model.slice(0, 128) } : {}),
1088
+ ...(typeof o.stop === "string" ? { stop: o.stop.slice(0, 64) } : {}),
1089
+ tokens: { input: num(t.input), output: num(t.output), cacheRead: num(t.cacheRead), cacheWrite: num(t.cacheWrite) },
1090
+ tools: Math.floor(num(o.tools)),
1091
+ ...(typeof o.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(o.subagent) ? { subagent: o.subagent } : {}),
1092
+ };
1093
+ }
1043
1094
  export function parseDriverLine(line) {
1044
1095
  const trimmed = line.trim();
1045
1096
  if (!trimmed.startsWith("{"))
@@ -1059,6 +1110,10 @@ export function parseDriverLine(line) {
1059
1110
  ...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
1060
1111
  ...(parsed.check === true ? { check: true } : {}),
1061
1112
  ...(isHookRecord(parsed.hook) ? { hook: parsed.hook } : {}),
1113
+ ...(callIoOf(parsed.io) ? { io: callIoOf(parsed.io) } : {}),
1114
+ ...(typeof parsed.inCall === "string" && parsed.inCall.length <= 128 ? { inCall: parsed.inCall } : {}),
1115
+ ...(modelCallOf(parsed.turn) ? { turn: modelCallOf(parsed.turn) } : {}),
1116
+ ...(parsed.responding === true ? { responding: true } : {}),
1062
1117
  };
1063
1118
  }
1064
1119
  if (parsed.e === "done") {