@foldrun/core 2.4.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 (132) 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 +20 -6
  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/network.d.ts +12 -0
  37. package/dist/src/network.js +25 -1
  38. package/dist/src/notify.d.ts +16 -0
  39. package/dist/src/notify.js +17 -7
  40. package/dist/src/okf.js +1 -1
  41. package/dist/src/online-checks.d.ts +76 -0
  42. package/dist/src/online-checks.js +432 -0
  43. package/dist/src/operator.d.ts +277 -0
  44. package/dist/src/operator.js +176 -2
  45. package/dist/src/otel-config.d.ts +18 -0
  46. package/dist/src/otel-config.js +69 -0
  47. package/dist/src/otel.d.ts +38 -0
  48. package/dist/src/otel.js +85 -0
  49. package/dist/src/outcome.d.ts +40 -0
  50. package/dist/src/outcome.js +77 -0
  51. package/dist/src/package.js +1 -1
  52. package/dist/src/platform.d.ts +1 -1
  53. package/dist/src/prompt-layers.d.ts +30 -0
  54. package/dist/src/prompt-layers.js +77 -0
  55. package/dist/src/provider-health.js +1 -1
  56. package/dist/src/providers.d.ts +5 -0
  57. package/dist/src/providers.js +2 -2
  58. package/dist/src/run-container.d.ts +16 -2
  59. package/dist/src/run-container.js +57 -5
  60. package/dist/src/runner.d.ts +53 -2
  61. package/dist/src/runner.js +356 -70
  62. package/dist/src/runs-filter.d.ts +48 -0
  63. package/dist/src/runs-filter.js +73 -0
  64. package/dist/src/scan.d.ts +19 -1
  65. package/dist/src/scan.js +60 -7
  66. package/dist/src/starter.js +4 -2
  67. package/dist/src/step-exec.d.ts +29 -1
  68. package/dist/src/step-exec.js +106 -6
  69. package/dist/src/store.d.ts +130 -3
  70. package/dist/src/store.js +145 -6
  71. package/dist/src/tokens.d.ts +38 -0
  72. package/dist/src/tokens.js +57 -0
  73. package/dist/src/tool-names.js +1 -1
  74. package/dist/src/tool-programs.js +1 -1
  75. package/dist/src/trace-tree.d.ts +40 -0
  76. package/dist/src/trace-tree.js +156 -0
  77. package/dist/src/translator.d.ts +8 -0
  78. package/dist/src/translator.js +7 -2
  79. package/dist/src/trigger-log.d.ts +1 -1
  80. package/dist/src/workspace-check.d.ts +18 -0
  81. package/dist/src/workspace-check.js +34 -0
  82. package/package.json +2 -2
  83. package/src/agent-activity.ts +32 -0
  84. package/src/agent-import.ts +1 -1
  85. package/src/agent-tools.ts +1 -1
  86. package/src/agents-md.ts +1 -1
  87. package/src/alerts.ts +190 -0
  88. package/src/browser-pod.ts +3 -0
  89. package/src/capture.ts +88 -0
  90. package/src/compare.ts +146 -0
  91. package/src/completions.ts +1 -0
  92. package/src/deploy.ts +184 -10
  93. package/src/egress.ts +12 -2
  94. package/src/evals.ts +19 -6
  95. package/src/frontmatter-fields.ts +9 -0
  96. package/src/frontmatter-schema.ts +3 -0
  97. package/src/history.ts +13 -0
  98. package/src/hooks.ts +1 -1
  99. package/src/insights.ts +174 -0
  100. package/src/issues.ts +396 -0
  101. package/src/library.ts +1 -1
  102. package/src/limits.ts +38 -5
  103. package/src/linkable.ts +1 -1
  104. package/src/matter.ts +33 -0
  105. package/src/model-credential.ts +45 -0
  106. package/src/network.ts +25 -1
  107. package/src/notify.ts +22 -7
  108. package/src/okf.ts +1 -1
  109. package/src/online-checks.ts +450 -0
  110. package/src/operator.ts +213 -3
  111. package/src/otel-config.ts +67 -0
  112. package/src/otel.ts +100 -0
  113. package/src/outcome.ts +90 -0
  114. package/src/package.ts +1 -1
  115. package/src/platform.ts +1 -1
  116. package/src/prompt-layers.ts +90 -0
  117. package/src/provider-health.ts +1 -1
  118. package/src/providers.ts +7 -2
  119. package/src/run-container.ts +66 -7
  120. package/src/runner.ts +363 -46
  121. package/src/runs-filter.ts +88 -0
  122. package/src/scan.ts +68 -7
  123. package/src/starter.ts +4 -2
  124. package/src/step-exec.ts +147 -9
  125. package/src/store.ts +254 -8
  126. package/src/tokens.ts +68 -0
  127. package/src/tool-names.ts +1 -1
  128. package/src/tool-programs.ts +1 -1
  129. package/src/trace-tree.ts +187 -0
  130. package/src/translator.ts +13 -2
  131. package/src/trigger-log.ts +1 -0
  132. 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 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";
@@ -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 matter 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";
@@ -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";
@@ -49,6 +50,8 @@ export interface ContainerStepInput {
49
50
  /** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
50
51
  schema?: Record<string, unknown> | boolean;
51
52
  maxTurns?: number;
53
+ /** `capture:` resolved host-side (FOL-54 step 4) — see ExecOptions. */
54
+ capture?: "metadata" | "full";
52
55
  /** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
53
56
  * count reads (limits.ts) — enforced in the pod by the same hook. */
54
57
  limits?: Record<string, number>;
@@ -67,6 +70,13 @@ export interface ContainerStepInput {
67
70
  ask?: {
68
71
  timeoutSec: number;
69
72
  };
73
+ /** tools: [peers] — this step's agent and the other steps of its
74
+ * parallel group, gathered host-side at launch. The driver rebuilds the
75
+ * colleagues' tools in here over the lease's /peers door. */
76
+ peers?: {
77
+ self: string;
78
+ siblings: PeerInfo[];
79
+ };
70
80
  /** A Chat-Completions provider: the driver starts the runtime's translator
71
81
  * on loopback and points the SDK at it. Null or absent: the env's
72
82
  * ANTHROPIC_BASE_URL is spoken to directly. */
@@ -374,6 +384,10 @@ export declare function parseDriverLine(line: string): {
374
384
  subagent?: string;
375
385
  check?: boolean;
376
386
  hook?: HookRecord;
387
+ io?: CallIo;
388
+ turn?: ModelCall;
389
+ inCall?: string;
390
+ responding?: true;
377
391
  } | ({
378
392
  e: "done";
379
393
  } & ContainerStepOutcome) | null;
@@ -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";
@@ -475,7 +475,7 @@ try {
475
475
  const { buildScriptTools } = await import("@foldrun/core/script-tools");
476
476
  const { buildConsultTools } = await import("@foldrun/core/agent-tools");
477
477
  const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
478
- const { httpChannel, buildAskTool, inboxReader } = await import("@foldrun/core/operator");
478
+ const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import("@foldrun/core/operator");
479
479
  const { startTranslator } = await import("@foldrun/core/translator");
480
480
  const { prepareRuntime } = await import("@foldrun/core/runtime");
481
481
  const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
@@ -539,11 +539,18 @@ try {
539
539
  // A long result is kept in a file only when the agent can read it back.
540
540
  // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)
541
541
  const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === "Read" || t === "Grep") };
542
- const api = buildApiTools("", input.apis, undefined, { env, missing: [] }, keepIn);
542
+ // A declared secret named like a model variable crosses as
543
+ // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own
544
+ // settings, and the agent's API and script tools and verify: get the value
545
+ // back under the name it declared. Built here, after the runtime and any
546
+ // mid-step install have added their paths to env.
547
+ const toolEnv = { ...env };
548
+ for (const [k, v] of Object.entries(env)) if (k.startsWith("FOLDRUN_DECLARED_")) toolEnv[k.slice("FOLDRUN_DECLARED_".length)] = v;
549
+ const api = buildApiTools("", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);
543
550
  const script = buildScriptTools(
544
551
  agentDir,
545
552
  input.scripts,
546
- { ...env, ...runtime.env },
553
+ toolEnv,
547
554
  "/library/scripts",
548
555
  runtime.interpreters,
549
556
  null,
@@ -566,6 +573,7 @@ try {
566
573
  // ask_person when tools: [ask], and the run's inbox after every tool call.
567
574
  const channel = httpChannel(env.FOLDRUN_EGRESS);
568
575
  const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
576
+ const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
569
577
  const inbox = channel ? inboxReader(channel, emit) : undefined;
570
578
 
571
579
  mark("tools");
@@ -587,6 +595,7 @@ try {
587
595
  ...(history.server ? { foldrun_history: history.server } : {}),
588
596
  ...(desks.server ? { foldrun_desks: desks.server } : {}),
589
597
  ...(ask.server ? { foldrun_ask: ask.server } : {}),
598
+ ...(peers.server ? { foldrun_peers: peers.server } : {}),
590
599
  ...(install.server ? { foldrun_install: install.server } : {}),
591
600
  ...input.mcpServers,
592
601
  },
@@ -598,10 +607,11 @@ try {
598
607
  budgetNote: input.budgetNote,
599
608
  price: input.price,
600
609
  verify: input.verify,
601
- verifyEnv: env,
610
+ verifyEnv: toolEnv,
602
611
  output: input.output,
603
612
  schema: input.schema,
604
613
  maxTurns: input.maxTurns,
614
+ ...(input.capture === "metadata" || input.capture === "full" ? { capture: input.capture } : {}),
605
615
  ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
606
616
  ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),
607
617
  // Slim browsing: the platform put this step on the slim image to browse
@@ -1040,6 +1050,44 @@ export function buildFailureSummary(log, opts = {}) {
1040
1050
  return `${head}\n--- the last ${tail} characters of the build log ---\n${log.slice(-tail)}`;
1041
1051
  }
1042
1052
  // ---------- running one step ----------
1053
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
1054
+ /** A tool call's content from the sandbox, as far as it is well-formed and
1055
+ * within the cap; anything else is dropped (FOL-54 step 4). */
1056
+ function callIoOf(v) {
1057
+ if (!v || typeof v !== "object")
1058
+ return null;
1059
+ const o = v;
1060
+ // The cap plus the slack the runner masks inside before it cuts.
1061
+ const text = (x) => (typeof x === "string" ? x.slice(0, CAPTURE_CAP + 4096) : undefined);
1062
+ const io = {
1063
+ ...(text(o.input) !== undefined ? { input: text(o.input) } : {}),
1064
+ ...(text(o.output) !== undefined ? { output: text(o.output) } : {}),
1065
+ ...(typeof o.inputChars === "number" ? { inputChars: num(o.inputChars) } : {}),
1066
+ ...(typeof o.outputChars === "number" ? { outputChars: num(o.outputChars) } : {}),
1067
+ ...(o.truncated === true ? { truncated: true } : {}),
1068
+ };
1069
+ return io.input !== undefined || io.output !== undefined ? io : null;
1070
+ }
1071
+ /** One model call from the sandbox, well-formed or dropped. */
1072
+ function modelCallOf(v) {
1073
+ if (!v || typeof v !== "object")
1074
+ return null;
1075
+ const o = v;
1076
+ const t = (o.tokens ?? {});
1077
+ if (typeof o.id !== "string" || !o.id || o.id.length > 128 || typeof o.at !== "string" || Number.isNaN(Date.parse(o.at)))
1078
+ return null;
1079
+ return {
1080
+ id: o.id,
1081
+ n: Math.max(1, Math.floor(num(o.n))),
1082
+ at: o.at,
1083
+ ms: num(o.ms),
1084
+ ...(typeof o.model === "string" ? { model: o.model.slice(0, 128) } : {}),
1085
+ ...(typeof o.stop === "string" ? { stop: o.stop.slice(0, 64) } : {}),
1086
+ tokens: { input: num(t.input), output: num(t.output), cacheRead: num(t.cacheRead), cacheWrite: num(t.cacheWrite) },
1087
+ tools: Math.floor(num(o.tools)),
1088
+ ...(typeof o.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(o.subagent) ? { subagent: o.subagent } : {}),
1089
+ };
1090
+ }
1043
1091
  export function parseDriverLine(line) {
1044
1092
  const trimmed = line.trim();
1045
1093
  if (!trimmed.startsWith("{"))
@@ -1059,6 +1107,10 @@ export function parseDriverLine(line) {
1059
1107
  ...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
1060
1108
  ...(parsed.check === true ? { check: true } : {}),
1061
1109
  ...(isHookRecord(parsed.hook) ? { hook: parsed.hook } : {}),
1110
+ ...(callIoOf(parsed.io) ? { io: callIoOf(parsed.io) } : {}),
1111
+ ...(typeof parsed.inCall === "string" && parsed.inCall.length <= 128 ? { inCall: parsed.inCall } : {}),
1112
+ ...(modelCallOf(parsed.turn) ? { turn: modelCallOf(parsed.turn) } : {}),
1113
+ ...(parsed.responding === true ? { responding: true } : {}),
1062
1114
  };
1063
1115
  }
1064
1116
  if (parsed.e === "done") {
@@ -33,6 +33,46 @@ export declare function slotPool(size: number): {
33
33
  acquire: () => Promise<void>;
34
34
  release: () => void;
35
35
  };
36
+ export declare function markResponding(step: StepRecord): void;
37
+ /**
38
+ * Is this step's prompt in the cache yet? An entry is readable from the
39
+ * moment the response to the request that wrote it begins streaming
40
+ * (Anthropic's prompt-caching docs), which is what `responding` marks.
41
+ * The first text, tool call or recorded model call says the same a little
42
+ * later, for an executor that cannot see the stream (the stub, an older
43
+ * driver). Resolves on the first of those, or when the step is over
44
+ * without one (a refusal before the first turn, a failed start).
45
+ */
46
+ export declare function untilFirstReply(step: StepRecord, done: Promise<unknown>, pollMs?: number): Promise<void>;
47
+ /**
48
+ * Steps of one agent launched together share a prompt prefix — tools and
49
+ * system prompt — and that is what the model's cache keeps. The cache is
50
+ * written by the first request that carries a prefix and readable only
51
+ * once that response has begun, so N copies started at once all write it
52
+ * and none reads: a fan-out of twenty paid twenty cold prefixes. The gate
53
+ * lets the first instance of each agent go alone and holds its siblings
54
+ * until its first response has begun (or it ended); then they all start
55
+ * and read what it wrote — Claude Code holds a workflow's fan-out the same
56
+ * way. Not a fixed delay: a sandbox takes seconds to start before its
57
+ * first model call, so a timer from launch would release the siblings
58
+ * before there was anything to read. Keyed by agent and model, since a
59
+ * cache is per model.
60
+ */
61
+ export declare function warmGate(launching: StepRecord[], pollMs?: number): {
62
+ /** Claim the step's place — synchronously, before anything awaits, or
63
+ * the copies that reach their first await together would all find no
64
+ * lead and all go (a `parallel:` pool's acquire was exactly that).
65
+ * The first of its agent leads and gets null; a later one gets the
66
+ * lead's gate to await before it starts; a lone step gets null too. */
67
+ claim(step: StepRecord): Promise<void> | null;
68
+ /** A lead that will not run after all (the run was stopped while it
69
+ * waited for a pool slot) still opens its gate — or its siblings,
70
+ * which also wake to the stop, would wait on it forever. */
71
+ release(step: StepRecord): void;
72
+ /** Run the step; a lead opens its agent's gate on its first reply (or
73
+ * its end), whatever else happens to it. */
74
+ run(step: StepRecord, launch: () => Promise<void>): Promise<void>;
75
+ };
36
76
  export interface DiscoveredSkill {
37
77
  name: string;
38
78
  description: string;
@@ -258,6 +298,8 @@ grantsOnly?: boolean): {
258
298
  * reported by the step, not here, so it lands in the run's trace. */
259
299
  clock: ClockChoice;
260
300
  systemPrompt: string;
301
+ /** What the record keeps of each layer of that prompt. */
302
+ promptLayers: import("./tokens.ts").PromptLayerRecord[];
261
303
  allowed: string[];
262
304
  disabled: string[];
263
305
  unknownTools: string[];
@@ -280,6 +322,7 @@ grantsOnly?: boolean): {
280
322
  ask: {
281
323
  timeoutSec: number;
282
324
  } | null;
325
+ peers: boolean;
283
326
  runtime: {
284
327
  log: string[];
285
328
  error: string | null;
@@ -328,6 +371,7 @@ grantsOnly?: boolean): {
328
371
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
329
372
  reasoningEffort?: boolean;
330
373
  params?: Record<string, unknown>;
374
+ cacheKey?: string;
331
375
  label?: string;
332
376
  egress?: string;
333
377
  timezone: string;
@@ -340,6 +384,7 @@ grantsOnly?: boolean): {
340
384
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
341
385
  reasoningEffort?: boolean;
342
386
  params?: Record<string, unknown>;
387
+ cacheKey?: string;
343
388
  label?: string;
344
389
  egress?: string;
345
390
  timezone: string;
@@ -583,7 +628,13 @@ export interface Reconciliation {
583
628
  * outlives any process. A run whose person already answered is not waiting,
584
629
  * and is picked back up — see below.
585
630
  */
586
- export declare function reconcileRuns(tenant: string, now?: number, live?: ReadonlySet<string>): Reconciliation[];
631
+ /** Told when a run reconcile closed, or resumed and saw to its end — so the
632
+ * platform settles, notifies and chains it as it would a run its worker
633
+ * drove (a run closed here had spent tokens, and nobody was charged or
634
+ * told). Not told when a resumed run parks again: whatever resumes it next
635
+ * goes through the worker. */
636
+ export type ReconcileEnd = (tenant: string, workspace: string, runId: string) => void | Promise<void>;
637
+ export declare function reconcileRuns(tenant: string, now?: number, live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[];
587
638
  /**
588
639
  * The same, for every account this installation holds.
589
640
  *
@@ -591,7 +642,7 @@ export declare function reconcileRuns(tenant: string, now?: number, live?: Reado
591
642
  * one hard-coded account left every other account's interrupted runs marked
592
643
  * `running` forever, with no process left to finish them.
593
644
  */
594
- export declare function reconcileAllRuns(now?: number, live?: ReadonlySet<string>): Reconciliation[];
645
+ export declare function reconcileAllRuns(now?: number, live?: ReadonlySet<string>, onEnd?: ReconcileEnd): Reconciliation[];
595
646
  /**
596
647
  * Wait for a run to stop being in progress.
597
648
  *