@foldrun/core 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +2 -2
  4. package/dist/src/agent-tools.js +2 -2
  5. package/dist/src/agents-md.d.ts +5 -1
  6. package/dist/src/agents-md.js +42 -18
  7. package/dist/src/alerts.d.ts +60 -0
  8. package/dist/src/alerts.js +161 -0
  9. package/dist/src/browser-pod.js +4 -0
  10. package/dist/src/capture.d.ts +23 -0
  11. package/dist/src/capture.js +80 -0
  12. package/dist/src/compare.d.ts +57 -0
  13. package/dist/src/compare.js +116 -0
  14. package/dist/src/completions.js +26 -3
  15. package/dist/src/deploy.d.ts +36 -1
  16. package/dist/src/deploy.js +237 -13
  17. package/dist/src/egress.d.ts +16 -1
  18. package/dist/src/evals.d.ts +27 -2
  19. package/dist/src/evals.js +62 -19
  20. package/dist/src/flow-lint.d.ts +14 -0
  21. package/dist/src/flow-lint.js +35 -0
  22. package/dist/src/flow-patterns.js +29 -14
  23. package/dist/src/frontmatter-fields.d.ts +27 -0
  24. package/dist/src/frontmatter-fields.js +60 -3
  25. package/dist/src/frontmatter-guard.d.ts +19 -0
  26. package/dist/src/frontmatter-guard.js +67 -0
  27. package/dist/src/frontmatter-schema.d.ts +2 -2
  28. package/dist/src/frontmatter-schema.js +33 -2
  29. package/dist/src/frontmatter-text.d.ts +31 -0
  30. package/dist/src/frontmatter-text.js +102 -0
  31. package/dist/src/history.d.ts +4 -0
  32. package/dist/src/history.js +15 -0
  33. package/dist/src/hooks.js +1 -1
  34. package/dist/src/insights.d.ts +30 -0
  35. package/dist/src/insights.js +152 -0
  36. package/dist/src/issues.d.ts +140 -0
  37. package/dist/src/issues.js +302 -0
  38. package/dist/src/judge-verdict.d.ts +19 -0
  39. package/dist/src/judge-verdict.js +54 -0
  40. package/dist/src/library.js +1 -1
  41. package/dist/src/limits.d.ts +8 -0
  42. package/dist/src/limits.js +36 -5
  43. package/dist/src/linkable.js +1 -1
  44. package/dist/src/matter.d.ts +42 -0
  45. package/dist/src/matter.js +132 -0
  46. package/dist/src/model-credential.d.ts +14 -0
  47. package/dist/src/model-credential.js +40 -0
  48. package/dist/src/network.d.ts +12 -1
  49. package/dist/src/network.js +35 -8
  50. package/dist/src/notify.d.ts +16 -0
  51. package/dist/src/notify.js +17 -7
  52. package/dist/src/okf.js +1 -1
  53. package/dist/src/online-checks.d.ts +76 -0
  54. package/dist/src/online-checks.js +432 -0
  55. package/dist/src/operator.d.ts +277 -0
  56. package/dist/src/operator.js +176 -2
  57. package/dist/src/otel-config.d.ts +18 -0
  58. package/dist/src/otel-config.js +69 -0
  59. package/dist/src/otel.d.ts +38 -0
  60. package/dist/src/otel.js +85 -0
  61. package/dist/src/outcome.d.ts +40 -0
  62. package/dist/src/outcome.js +77 -0
  63. package/dist/src/package.js +9 -2
  64. package/dist/src/platform.d.ts +1 -1
  65. package/dist/src/prompt-layers.d.ts +30 -0
  66. package/dist/src/prompt-layers.js +77 -0
  67. package/dist/src/provider-health.js +2 -2
  68. package/dist/src/providers.d.ts +5 -0
  69. package/dist/src/providers.js +2 -2
  70. package/dist/src/run-container.d.ts +25 -2
  71. package/dist/src/run-container.js +61 -6
  72. package/dist/src/runner.d.ts +53 -2
  73. package/dist/src/runner.js +471 -102
  74. package/dist/src/runs-filter.d.ts +48 -0
  75. package/dist/src/runs-filter.js +73 -0
  76. package/dist/src/scan.d.ts +30 -1
  77. package/dist/src/scan.js +145 -14
  78. package/dist/src/starter.js +27 -2
  79. package/dist/src/step-exec.d.ts +39 -3
  80. package/dist/src/step-exec.js +118 -15
  81. package/dist/src/store.d.ts +144 -3
  82. package/dist/src/store.js +150 -7
  83. package/dist/src/tokens.d.ts +38 -0
  84. package/dist/src/tokens.js +57 -0
  85. package/dist/src/tool-names.js +1 -1
  86. package/dist/src/tool-programs.js +1 -1
  87. package/dist/src/trace-tree.d.ts +40 -0
  88. package/dist/src/trace-tree.js +156 -0
  89. package/dist/src/translator.d.ts +8 -0
  90. package/dist/src/translator.js +7 -2
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/workspace-check.d.ts +18 -0
  93. package/dist/src/workspace-check.js +34 -0
  94. package/package.json +2 -2
  95. package/src/agent-activity.ts +32 -0
  96. package/src/agent-import.ts +2 -2
  97. package/src/agent-tools.ts +2 -2
  98. package/src/agents-md.ts +39 -14
  99. package/src/alerts.ts +190 -0
  100. package/src/browser-pod.ts +3 -0
  101. package/src/capture.ts +88 -0
  102. package/src/compare.ts +146 -0
  103. package/src/completions.ts +27 -3
  104. package/src/deploy.ts +238 -12
  105. package/src/egress.ts +12 -2
  106. package/src/evals.ts +64 -19
  107. package/src/flow-lint.ts +36 -0
  108. package/src/flow-patterns.ts +26 -13
  109. package/src/frontmatter-fields.ts +83 -3
  110. package/src/frontmatter-guard.ts +70 -0
  111. package/src/frontmatter-schema.ts +33 -4
  112. package/src/frontmatter-text.ts +109 -0
  113. package/src/history.ts +13 -0
  114. package/src/hooks.ts +1 -1
  115. package/src/insights.ts +174 -0
  116. package/src/issues.ts +396 -0
  117. package/src/judge-verdict.ts +52 -0
  118. package/src/library.ts +1 -1
  119. package/src/limits.ts +38 -5
  120. package/src/linkable.ts +1 -1
  121. package/src/matter.ts +145 -0
  122. package/src/model-credential.ts +45 -0
  123. package/src/network.ts +35 -7
  124. package/src/notify.ts +22 -7
  125. package/src/okf.ts +1 -1
  126. package/src/online-checks.ts +450 -0
  127. package/src/operator.ts +213 -3
  128. package/src/otel-config.ts +67 -0
  129. package/src/otel.ts +100 -0
  130. package/src/outcome.ts +90 -0
  131. package/src/package.ts +8 -2
  132. package/src/platform.ts +1 -1
  133. package/src/prompt-layers.ts +90 -0
  134. package/src/provider-health.ts +2 -2
  135. package/src/providers.ts +7 -2
  136. package/src/run-container.ts +77 -8
  137. package/src/runner.ts +477 -79
  138. package/src/runs-filter.ts +88 -0
  139. package/src/scan.ts +153 -14
  140. package/src/starter.ts +27 -2
  141. package/src/step-exec.ts +168 -17
  142. package/src/store.ts +268 -9
  143. package/src/tokens.ts +68 -0
  144. package/src/tool-names.ts +1 -1
  145. package/src/tool-programs.ts +1 -1
  146. package/src/trace-tree.ts +187 -0
  147. package/src/translator.ts +13 -2
  148. package/src/trigger-log.ts +1 -0
  149. package/src/workspace-check.ts +41 -0
package/dist/src/store.js CHANGED
@@ -27,7 +27,8 @@ import { recordRevision, registerTreeReader } from "./history.js";
27
27
  import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.js";
28
28
  import { platform } from "./platform.js";
29
29
  import { toLF, asRaw } from "./eol.js";
30
- import matter from "gray-matter";
30
+ import matter, { frontmatterErrorText, configMatter } from "./matter.js";
31
+ import { readAgentsMdKeys } from "./agents-md.js";
31
32
  /**
32
33
  * The frontmatter of one markdown file, as the runtime reads it. Exported
33
34
  * for the CLI: `foldrun probe` used to import gray-matter itself, which is
@@ -38,7 +39,7 @@ import matter from "gray-matter";
38
39
  * the runtime, and there is nothing to fail to import.
39
40
  */
40
41
  export function readFrontmatter(file) {
41
- return matter(fs.readFileSync(file, "utf8")).data;
42
+ return configMatter(fs.readFileSync(file, "utf8")).data;
42
43
  }
43
44
  import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.js";
44
45
  import { refNames } from "./refs.js";
@@ -1118,7 +1119,18 @@ export function listAgents(tenant, workspace) {
1118
1119
  .filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
1119
1120
  .sort()
1120
1121
  .map((name) => {
1121
- const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
1122
+ // One agent whose YAML does not parse is that agent's problem, not
1123
+ // the account's: listed with defaults and the reason, never thrown
1124
+ // out of a listing every page, the scheduler and the worker's boot
1125
+ // read.
1126
+ let data = {};
1127
+ let frontmatterError;
1128
+ try {
1129
+ ({ data } = configMatter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8")));
1130
+ }
1131
+ catch (err) {
1132
+ frontmatterError = frontmatterErrorText(err);
1133
+ }
1122
1134
  const tools = refNames(data.tools);
1123
1135
  const actions = webConfig(data, { ...accountFront, ...workspaceFront }).actions;
1124
1136
  const readsWeb = tools.includes("web") && (!actions || actions.some((a) => a === "fetch" || a === "browse"));
@@ -1141,6 +1153,7 @@ export function listAgents(tenant, workspace) {
1141
1153
  network,
1142
1154
  name,
1143
1155
  description: data.description ?? "",
1156
+ ...resolveOwner(data.owner, workspaceFront.owner, accountFront.owner),
1144
1157
  model: data.model ?? "default",
1145
1158
  effort: data.effort ?? null,
1146
1159
  tools,
@@ -1164,9 +1177,19 @@ export function listAgents(tenant, workspace) {
1164
1177
  ...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
1165
1178
  networkProblems: networkProblems(data.network),
1166
1179
  inlineTools: inlineToolNames(data),
1180
+ ...(frontmatterError ? { frontmatterError } : {}),
1167
1181
  };
1168
1182
  });
1169
1183
  }
1184
+ // Kept here for callers that import it from the store; it lives in matter.ts
1185
+ // so the scanner can use it without the store.
1186
+ export { frontmatterErrorText };
1187
+ /** The body of a file whose frontmatter would not parse: what follows the
1188
+ * closing fence, so a flow's steps can still be read and shown. */
1189
+ function bodyAfterFrontmatter(raw) {
1190
+ const m = raw.match(/^---\n[\s\S]*?\n---[ \t]*(?:\n|$)/);
1191
+ return m ? raw.slice(m[0].length) : raw;
1192
+ }
1170
1193
  // A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
1171
1194
  // which is how one flow triggers another.
1172
1195
  // `(\S.*|)` rather than `(.*)`: the instruction either starts with a
@@ -1326,11 +1349,26 @@ export function parseFlow(file, raw) {
1326
1349
  // every line kept its "\r", which STEP_RE's `.` will not match. Read it as
1327
1350
  // LF; the line count, and so every step's `line`, is the same.
1328
1351
  raw = raw.replace(/\r\n/g, "\n");
1329
- const { data, content } = matter(raw);
1352
+ // Frontmatter that does not parse is a flow-level problem (an error in
1353
+ // check), not a throw: listFlows feeds every workspace listing, the
1354
+ // scheduler and the worker's boot. With no frontmatter read the flow is
1355
+ // manual, so a broken schedule never fires.
1356
+ let data = {};
1357
+ let content;
1358
+ let frontError = null;
1359
+ try {
1360
+ ({ data, content } = configMatter(raw));
1361
+ }
1362
+ catch (err) {
1363
+ frontError = frontmatterErrorText(err);
1364
+ content = bodyAfterFrontmatter(raw);
1365
+ }
1330
1366
  const steps = [];
1331
1367
  // Flow-level problems: a malformed step target (below) and unreadable
1332
1368
  // frontmatter enums (before the return). Reported as errors by flow-lint.
1333
1369
  const frontProblems = [];
1370
+ if (frontError)
1371
+ frontProblems.push(frontError);
1334
1372
  // Frontmatter is stripped by matter(), so add it back to keep line numbers
1335
1373
  // pointing at the real file rather than the body.
1336
1374
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
@@ -1573,6 +1611,7 @@ export function parseFlow(file, raw) {
1573
1611
  }
1574
1612
  };
1575
1613
  badEnum("overlap", data.overlap, ["skip", "queue"]);
1614
+ badEnum("issues", data.issues, ["stay-open", "auto-close"]);
1576
1615
  badEnum("priority", data.priority, ["high", "normal", "low"]);
1577
1616
  badEnum("catchup", data.catchup, ["none", "last"]);
1578
1617
  badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
@@ -1587,6 +1626,7 @@ export function parseFlow(file, raw) {
1587
1626
  model: data.model ?? null,
1588
1627
  effort: data.effort ?? null,
1589
1628
  overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
1629
+ ...(data.issues === "auto-close" || data.issues === "stay-open" ? { issues: data.issues } : {}),
1590
1630
  live: data.live === true,
1591
1631
  priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
1592
1632
  budget: runBudget(data.budget),
@@ -1622,6 +1662,7 @@ export function parseFlow(file, raw) {
1622
1662
  approvers: approverList(data.approvers),
1623
1663
  approveWithin: durationOf(data.approve_within),
1624
1664
  description: typeof data.description === "string" ? data.description.trim() || null : null,
1665
+ ...resolveOwner(data.owner, undefined, undefined, "flow"),
1625
1666
  frontProblems,
1626
1667
  steps,
1627
1668
  };
@@ -1932,11 +1973,38 @@ export function listFlows(tenant, workspace) {
1932
1973
  const dir = path.join(workspaceDir(tenant, workspace), "flows");
1933
1974
  if (!fs.existsSync(dir))
1934
1975
  return [];
1976
+ const owned = flowOwner(tenant, workspace);
1935
1977
  return fs
1936
1978
  .readdirSync(dir)
1937
1979
  .filter((f) => f.endsWith(".md"))
1938
1980
  .sort()
1939
- .map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
1981
+ .map((f) => owned(parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"))));
1982
+ }
1983
+ /** A flow with its owner filled from the workspace and account when its own
1984
+ * file names none. */
1985
+ function flowOwner(tenant, workspace) {
1986
+ let above = null;
1987
+ return (flow) => {
1988
+ if (flow.owner)
1989
+ return flow;
1990
+ above ??= {
1991
+ workspace: readAgentsMdKeys(workspaceDir(tenant, workspace)).owner,
1992
+ account: readAgentsMdKeys(accountDir(tenant)).owner,
1993
+ };
1994
+ return { ...flow, ...resolveOwner(undefined, above.workspace, above.account) };
1995
+ };
1996
+ }
1997
+ /** `owner:` down the cascade: the file's own, the workspace AGENTS.md's, the
1998
+ * account's. A blank or non-string value is no value. `own` is labelled
1999
+ * "agent" or "flow" by the caller's kind; here it is whichever is given. */
2000
+ export function resolveOwner(own, workspace, account, ownKind = "agent") {
2001
+ const text = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
2002
+ for (const [v, from] of [[own, ownKind], [workspace, "workspace"], [account, "account"]]) {
2003
+ const t = text(v);
2004
+ if (t)
2005
+ return { owner: t, ownerFrom: from };
2006
+ }
2007
+ return { owner: null, ownerFrom: null };
1940
2008
  }
1941
2009
  /** One flow of a workspace by name, or null. The same read listFlows makes,
1942
2010
  * named so callers that want a single flow — the approval rules, the
@@ -1948,7 +2016,7 @@ export function readFlow(tenant, workspace, flowName) {
1948
2016
  for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
1949
2017
  const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
1950
2018
  if (flow.name === flowName)
1951
- return flow;
2019
+ return flowOwner(tenant, workspace)(flow);
1952
2020
  }
1953
2021
  return null;
1954
2022
  }
@@ -2568,6 +2636,8 @@ export function flowPatternTemplate(pattern, name, agents) {
2568
2636
  return FLOW_TEMPLATE(name, a(0, "my-agent"));
2569
2637
  }
2570
2638
  }
2639
+ /** The most of one tool input or output a run records (FOL-54 step 4). */
2640
+ export const CAPTURE_CAP = 64 * 1024;
2571
2641
  /** The step's totals across its attempts, from the rows. Null where no
2572
2642
  * attempt reported the figure — an in-process step rents no sandbox, and
2573
2643
  * billing reads null as zero, not as "unknown, charge something". */
@@ -2583,8 +2653,60 @@ export function sumAttempts(tries) {
2583
2653
  ...(turns.length ? { turns: turns.reduce((a, b) => a + b, 0) } : {}),
2584
2654
  };
2585
2655
  }
2586
- export { tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens } from "./tokens.js";
2656
+ export { tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens, promptLayersChanged, promptCacheNote } from "./tokens.js";
2587
2657
  import { sumTokens } from "./tokens.js";
2658
+ /** The marker rescueStep puts in a rescue's instruction; how a rescue record
2659
+ * written before `rescueOf` existed is still recognised. */
2660
+ export const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
2661
+ /** Is this record an on-fail rescue rather than a step of the flow? */
2662
+ export function isRescueRecord(step) {
2663
+ return !!step.rescueOf || (step.instruction ?? "").includes(RESCUE_MARKER);
2664
+ }
2665
+ // ---------------------------------------------------------------- captured calls
2666
+ //
2667
+ // What each tool call took and gave (FOL-54 step 4), beside the run under
2668
+ // runs/<run>/calls/<call>.json — out of the run record, which every list
2669
+ // and every page reads whole, and gone with the run (deleteRun removes
2670
+ // runs/<run>/). The runner writes them, secrets already masked.
2671
+ const SAFE_ID = /^[A-Za-z0-9_.-]{1,128}$/;
2672
+ function capturedCallFile(workspaceRoot, runId, callId) {
2673
+ if (!SAFE_ID.test(runId) || !SAFE_ID.test(callId) || callId.startsWith(".") || runId.startsWith("."))
2674
+ return null;
2675
+ return path.join(workspaceRoot, "runs", runId, "calls", `${callId}.json`);
2676
+ }
2677
+ /** Merge one part (the input, on the call; the output, on its completion)
2678
+ * into the call's file. */
2679
+ export function writeCapturedCall(workspaceRoot, runId, part) {
2680
+ const file = capturedCallFile(workspaceRoot, runId, part.id);
2681
+ if (!file)
2682
+ return;
2683
+ let prev = {};
2684
+ try {
2685
+ prev = JSON.parse(fs.readFileSync(file, "utf8"));
2686
+ }
2687
+ catch {
2688
+ /* the first part */
2689
+ }
2690
+ const next = { ...prev, ...part, truncated: Boolean(prev.truncated || part.truncated) || undefined };
2691
+ if (!next.truncated)
2692
+ delete next.truncated;
2693
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2694
+ const tmp = `${file}.${process.pid}.tmp`;
2695
+ fs.writeFileSync(tmp, JSON.stringify(next));
2696
+ fs.renameSync(tmp, file);
2697
+ }
2698
+ /** One recorded call of a run, or null (not recorded, or not a call). */
2699
+ export function readCapturedCall(tenant, workspace, runId, callId) {
2700
+ const file = capturedCallFile(workspaceDir(tenant, workspace), runId, callId);
2701
+ if (!file)
2702
+ return null;
2703
+ try {
2704
+ return JSON.parse(fs.readFileSync(file, "utf8"));
2705
+ }
2706
+ catch {
2707
+ return null;
2708
+ }
2709
+ }
2588
2710
  export function runFilePath(tenant, workspace, runId) {
2589
2711
  assertSafeName(runId, "run id");
2590
2712
  return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
@@ -2763,6 +2885,7 @@ export function summarizeRun(run) {
2763
2885
  ...(run.tags?.length ? { tags: run.tags } : {}),
2764
2886
  ...(run.startedBy ? { startedBy: run.startedBy } : {}),
2765
2887
  ...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
2888
+ ...(run.revision ? { revision: run.revision } : {}),
2766
2889
  awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
2767
2890
  steps: run.steps.map((s) => {
2768
2891
  const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
@@ -2781,10 +2904,30 @@ export function summarizeRun(run) {
2781
2904
  ...(s.status === "awaiting-approval" ? { instruction: s.instruction, waitingSince: s.events.at(-1)?.t ?? run.startedAt } : {}),
2782
2905
  ...(s.startedAt ? { startedAt: s.startedAt } : {}),
2783
2906
  ...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
2907
+ ...searchFacts(s),
2784
2908
  };
2785
2909
  }),
2786
2910
  };
2787
2911
  }
2912
+ /** What a step's row carries for search: the tools it called (once each),
2913
+ * how many tool calls errored, and its model. */
2914
+ function searchFacts(s) {
2915
+ const tools = new Set();
2916
+ let errors = 0;
2917
+ for (const e of s.events) {
2918
+ if (e.type !== "tool")
2919
+ continue;
2920
+ if (e.ms == null)
2921
+ tools.add(e.text);
2922
+ else if (e.err)
2923
+ errors++;
2924
+ }
2925
+ return {
2926
+ ...(tools.size ? { tools: [...tools].slice(0, 40) } : {}),
2927
+ ...(errors ? { toolErrors: errors } : {}),
2928
+ ...(s.model ? { model: s.model } : {}),
2929
+ };
2930
+ }
2788
2931
  /** A RunRecord with the shape a list reads and nothing else — see listAllRuns. */
2789
2932
  export function runFromSummary(s) {
2790
2933
  const { awaitingApproval: _awaiting, ...run } = s;
@@ -36,3 +36,41 @@ export declare function runTokens(run: {
36
36
  tokens?: TokenUsage | null;
37
37
  }[];
38
38
  }): TokenUsage | null;
39
+ /**
40
+ * One layer of a step's system prompt as the record keeps it — no text,
41
+ * only what tells two steps' prompts apart. The layers are assembled in
42
+ * prompt-layers.ts; this is the shape the run page and the CLI read.
43
+ */
44
+ export interface PromptLayerRecord {
45
+ name: string;
46
+ chars: number;
47
+ /** First 12 hex characters of the layer's SHA-256. */
48
+ hash: string;
49
+ /** False for a layer that changes between runs (memory, state, clock),
50
+ * placed after the cache boundary. Omitted when true. */
51
+ stable?: false;
52
+ }
53
+ /**
54
+ * Which layers differ between two steps' prompts, by name: changed, added
55
+ * or removed. Empty when the prompts are the same, or either step has no
56
+ * layers on record.
57
+ */
58
+ export declare function promptLayersChanged(prev: readonly PromptLayerRecord[] | null | undefined, next: readonly PromptLayerRecord[] | null | undefined): string[];
59
+ /** A step that wrote this share of its input to the cache, or more, gets a
60
+ * note saying why. Below it the cache did its job: a step's turns each
61
+ * write their new tail, which is a few percent of a long step's input. */
62
+ export declare const CACHE_WRITE_NOTE_SHARE = 0.2;
63
+ /**
64
+ * Why a step's prompt cache missed, in one line, or null when it did not:
65
+ * the share of input written to the cache, and what differed from the same
66
+ * agent's previous step in the run — "memory, clock changed since step 2".
67
+ * The agent's first step in a run writes its prefix whatever happens, and
68
+ * says so; the same prompt written again means the entry had expired
69
+ * (five minutes on an API key) or siblings started before it was readable.
70
+ * Steps without layers on record (before 20 Oct 2026) get no note.
71
+ */
72
+ export declare function promptCacheNote(steps: readonly {
73
+ agent: string;
74
+ tokens?: TokenUsage | null;
75
+ promptLayers?: readonly PromptLayerRecord[] | null;
76
+ }[], index: number): string | null;
@@ -56,3 +56,60 @@ export function runTokens(run) {
56
56
  const toks = run.steps.map((s) => s.tokens).filter((t) => !!t);
57
57
  return toks.length ? sumTokens(toks) : null;
58
58
  }
59
+ /**
60
+ * Which layers differ between two steps' prompts, by name: changed, added
61
+ * or removed. Empty when the prompts are the same, or either step has no
62
+ * layers on record.
63
+ */
64
+ export function promptLayersChanged(prev, next) {
65
+ if (!prev || !next)
66
+ return [];
67
+ const before = new Map(prev.map((l) => [l.name, l.hash]));
68
+ const after = new Map(next.map((l) => [l.name, l.hash]));
69
+ const out = [];
70
+ for (const [name, hash] of after)
71
+ if (before.get(name) !== hash)
72
+ out.push(name);
73
+ for (const name of before.keys())
74
+ if (!after.has(name))
75
+ out.push(name);
76
+ return out;
77
+ }
78
+ /** A step that wrote this share of its input to the cache, or more, gets a
79
+ * note saying why. Below it the cache did its job: a step's turns each
80
+ * write their new tail, which is a few percent of a long step's input. */
81
+ export const CACHE_WRITE_NOTE_SHARE = 0.2;
82
+ /**
83
+ * Why a step's prompt cache missed, in one line, or null when it did not:
84
+ * the share of input written to the cache, and what differed from the same
85
+ * agent's previous step in the run — "memory, clock changed since step 2".
86
+ * The agent's first step in a run writes its prefix whatever happens, and
87
+ * says so; the same prompt written again means the entry had expired
88
+ * (five minutes on an API key) or siblings started before it was readable.
89
+ * Steps without layers on record (before 20 Oct 2026) get no note.
90
+ */
91
+ export function promptCacheNote(steps, index) {
92
+ const step = steps[index];
93
+ if (!step?.promptLayers)
94
+ return null;
95
+ const t = step.tokens;
96
+ if (!t || t.cacheWrite === undefined || !(t.input > 0))
97
+ return null;
98
+ const share = t.cacheWrite / t.input;
99
+ if (share < CACHE_WRITE_NOTE_SHARE)
100
+ return null;
101
+ const head = `${Math.round(share * 100)}% of input written to the cache`;
102
+ let prev = -1;
103
+ for (let j = index - 1; j >= 0; j--) {
104
+ if (steps[j].agent === step.agent && steps[j].promptLayers) {
105
+ prev = j;
106
+ break;
107
+ }
108
+ }
109
+ if (prev < 0)
110
+ return `${head} — the agent's first step in this run`;
111
+ const changed = promptLayersChanged(steps[prev].promptLayers, step.promptLayers);
112
+ if (!changed.length)
113
+ return `${head} — the same prompt as step ${prev + 1}; the entry had expired or was still being written`;
114
+ return `${head} — ${changed.join(", ")} changed since step ${prev + 1}`;
115
+ }
@@ -47,7 +47,7 @@ export const BUILTIN_TOOLS = new Set([
47
47
  /** The platform's own groups, served in-process rather than by the SDK.
48
48
  * `install` is granted to every agent anyway (install-tool.ts); naming it is
49
49
  * accepted so a file that says so is not flagged. */
50
- export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "install"]);
50
+ export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "peers", "install"]);
51
51
  /** Would this name resolve to something the runtime provides? Built-ins win a
52
52
  * clash, so a tool file of the same name is shadowed rather than granted. */
53
53
  export function isRuntimeTool(name) {
@@ -15,7 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { builtinModules } from "node:module";
18
- import matter from "gray-matter";
18
+ import matter from "./matter.js";
19
19
  import { parseRuntime } from "./runtime.js";
20
20
  import { workspaceDir, workspaceTools } from "./store.js";
21
21
  import { libraryDir, libraryTools } from "./library.js";
@@ -0,0 +1,40 @@
1
+ import type { RunRecord } from "./store.ts";
2
+ export type SpanKind = "run" | "step" | "model" | "tool" | "error";
3
+ export type SpanStatus = "ok" | "error" | "running" | "waiting" | "skipped" | "pending";
4
+ export interface TraceSpan {
5
+ /** Stable within the run: "run", "s2", "s2/m3", "s2/t:<call>", "s2/e5". */
6
+ id: string;
7
+ kind: SpanKind;
8
+ /** What the line says: the flow, the agent, the model, the tool. */
9
+ label: string;
10
+ /** Epoch ms; null when the record does not say. */
11
+ start: number | null;
12
+ end: number | null;
13
+ status: SpanStatus;
14
+ /** Model and step spans: tokens (input is all input, as on the record). */
15
+ tokens?: {
16
+ input: number;
17
+ output: number;
18
+ cacheRead: number;
19
+ cacheWrite: number;
20
+ };
21
+ /** Step spans: the provider's token cost, USD. */
22
+ costUsd?: number | null;
23
+ model?: string;
24
+ /** Model spans: why it stopped. */
25
+ stop?: string;
26
+ /** Tool spans: the call id, and whether its input and output were recorded. */
27
+ call?: string;
28
+ captured?: boolean;
29
+ /** Error spans and failed tools: the words. */
30
+ text?: string;
31
+ subagent?: string;
32
+ /** Index into run.steps, on a step and everything under it. */
33
+ step?: number;
34
+ children: TraceSpan[];
35
+ }
36
+ export declare function buildTraceTree(run: RunRecord, now?: number): TraceSpan;
37
+ /** The ids from the root to the first thing that went wrong — what the
38
+ * view opens on; [] when nothing did. A failed tool beats the step's error
39
+ * line, which beats the failed step itself. */
40
+ export declare function failurePath(root: TraceSpan): string[];
@@ -0,0 +1,156 @@
1
+ // A run as one tree (FOL-72 step 5): run → step → model call → tool call,
2
+ // each with when it started and ended, what it cost in tokens and how it
3
+ // ended — the waterfall every agent-tracing tool opens on. Built from the
4
+ // run record alone (steps, step.calls, the paired tool events), so the run
5
+ // page, GET …/runs/<id>/trace and `foldrun report --tree` read one function.
6
+ //
7
+ // A tool call nests under the model call that asked for it: its event names
8
+ // the call (`inCall`) since 19 Oct 2026; older records are matched by time,
9
+ // to the last model call that answered before the tool started. Pure: no
10
+ // node imports, so the browser can use it.
11
+ const ms = (iso) => {
12
+ if (!iso)
13
+ return null;
14
+ const t = Date.parse(iso);
15
+ return Number.isNaN(t) ? null : t;
16
+ };
17
+ function stepStatus(s) {
18
+ if (s.status === "completed")
19
+ return "ok";
20
+ if (s.status === "failed")
21
+ return "error";
22
+ if (s.status === "running")
23
+ return "running";
24
+ if (s.status === "awaiting-approval")
25
+ return "waiting";
26
+ if (s.status === "skipped")
27
+ return "skipped";
28
+ return "pending";
29
+ }
30
+ /** Tool events paired: the call's start, its duration, whether it erred. */
31
+ function toolCalls(events) {
32
+ const byCall = new Map();
33
+ const out = [];
34
+ for (const e of events) {
35
+ if (e.type !== "tool")
36
+ continue;
37
+ if (e.call && e.ms != null && byCall.has(e.call)) {
38
+ const open = byCall.get(e.call);
39
+ open.end = (open.start ?? ms(e.t) ?? 0) + e.ms;
40
+ open.err ||= e.err === true;
41
+ open.captured ||= e.captured === true;
42
+ continue;
43
+ }
44
+ const c = { e, start: ms(e.t), end: null, err: e.err === true, captured: e.captured === true };
45
+ if (e.call)
46
+ byCall.set(e.call, c);
47
+ out.push(c);
48
+ }
49
+ return out;
50
+ }
51
+ function modelSpan(c, step) {
52
+ const at = ms(c.at);
53
+ return {
54
+ id: `s${step}/m${c.attempt && c.attempt > 1 ? `${c.attempt}.` : ""}${c.n}`,
55
+ kind: "model",
56
+ label: c.model ?? "model",
57
+ // The request went out `ms` before the response began arriving.
58
+ start: at !== null ? at - c.ms : null,
59
+ end: at,
60
+ status: "ok",
61
+ tokens: c.tokens,
62
+ ...(c.model ? { model: c.model } : {}),
63
+ ...(c.stop ? { stop: c.stop } : {}),
64
+ ...(c.subagent ? { subagent: c.subagent } : {}),
65
+ step,
66
+ children: [],
67
+ };
68
+ }
69
+ function stepSpan(s, i, now) {
70
+ const first = s.events[0] ? ms(s.events[0].t) : null;
71
+ const last = s.events.length ? ms(s.events[s.events.length - 1].t) : null;
72
+ const status = stepStatus(s);
73
+ const span = {
74
+ id: `s${i}`,
75
+ kind: "step",
76
+ label: s.agent,
77
+ start: ms(s.startedAt) ?? first,
78
+ end: ms(s.finishedAt) ?? (status === "running" ? now : last),
79
+ status,
80
+ ...(s.tokens ? { tokens: { input: s.tokens.input, output: s.tokens.output, cacheRead: s.tokens.cacheRead ?? 0, cacheWrite: s.tokens.cacheWrite ?? 0 } } : {}),
81
+ costUsd: s.costUsd ?? null,
82
+ ...(s.model ? { model: s.model } : {}),
83
+ step: i,
84
+ children: [],
85
+ };
86
+ const models = (s.calls ?? []).map((c) => ({ c, span: modelSpan(c, i) }));
87
+ const byId = new Map(models.map((m) => [m.c.id, m.span]));
88
+ const homeOf = (e, start) => {
89
+ if (e.inCall && byId.has(e.inCall))
90
+ return byId.get(e.inCall);
91
+ // Older records: the last model call (of the same agent) that answered
92
+ // before the tool started.
93
+ const same = models.filter((m) => (m.c.subagent ?? null) === (e.subagent ?? null) && m.span.end !== null && start !== null && m.span.end <= start + 50);
94
+ return same.length ? same[same.length - 1].span : span;
95
+ };
96
+ for (const t of toolCalls(s.events)) {
97
+ const home = homeOf(t.e, t.start);
98
+ const tool = {
99
+ id: `s${i}/t:${t.e.call ?? `${t.start}`}`,
100
+ kind: "tool",
101
+ label: t.e.text,
102
+ start: t.start,
103
+ end: t.end ?? (status === "running" ? now : t.start),
104
+ status: t.err ? "error" : t.end === null && status === "running" ? "running" : "ok",
105
+ ...(t.e.call ? { call: t.e.call } : {}),
106
+ ...(t.captured ? { captured: true } : {}),
107
+ ...(t.e.subagent ? { subagent: t.e.subagent } : {}),
108
+ step: i,
109
+ children: [],
110
+ };
111
+ home.children.push(tool);
112
+ }
113
+ // A model call lasts until the last tool it asked for has finished.
114
+ for (const m of models) {
115
+ const ends = m.span.children.map((c) => c.end).filter((x) => x !== null);
116
+ if (ends.length && m.span.end !== null)
117
+ m.span.end = Math.max(m.span.end, ...ends);
118
+ }
119
+ span.children.push(...models.map((m) => m.span));
120
+ // Errors, as leaves: why the step failed, read without opening the trace.
121
+ s.events.forEach((e, k) => {
122
+ if (e.type !== "error")
123
+ return;
124
+ span.children.push({ id: `s${i}/e${k}`, kind: "error", label: "error", start: ms(e.t), end: ms(e.t), status: "error", text: e.text, step: i, children: [] });
125
+ });
126
+ span.children.sort((a, b) => (a.start ?? Infinity) - (b.start ?? Infinity));
127
+ return span;
128
+ }
129
+ export function buildTraceTree(run, now = Date.now()) {
130
+ const status = run.status === "completed" ? "ok" : run.status === "failed" ? "error" : run.status === "awaiting-approval" ? "waiting" : run.status === "queued" ? "pending" : "running";
131
+ return {
132
+ id: "run",
133
+ kind: "run",
134
+ label: run.flow,
135
+ start: ms(run.startedAt),
136
+ end: ms(run.finishedAt) ?? (status === "running" || status === "waiting" ? now : null),
137
+ status,
138
+ children: run.steps.map((s, i) => stepSpan(s, i, now)),
139
+ };
140
+ }
141
+ /** The ids from the root to the first thing that went wrong — what the
142
+ * view opens on; [] when nothing did. A failed tool beats the step's error
143
+ * line, which beats the failed step itself. */
144
+ export function failurePath(root) {
145
+ let best = null;
146
+ const rank = (s) => (s.kind === "tool" ? 3 : s.kind === "error" ? 2 : s.kind === "step" ? 1 : 0);
147
+ const walk = (s, path) => {
148
+ const here = [...path, s.id];
149
+ if (s.status === "error" && s.kind !== "run" && (!best || rank(s) > best.rank))
150
+ best = { path: here, rank: rank(s) };
151
+ for (const c of s.children)
152
+ walk(c, here);
153
+ };
154
+ walk(root, []);
155
+ return best ? best.path : [];
156
+ }
@@ -28,6 +28,11 @@ export interface TranslatorSpec {
28
28
  /** The provider block's `params:` — merged into the outgoing body after
29
29
  * the translation, so a file can set what this format does not model. */
30
30
  params?: Record<string, unknown>;
31
+ /** `prompt_cache_key` for an endpoint that routes its prompt cache by
32
+ * one (OpenAI, Mistral): the same key for every request that shares a
33
+ * prefix — one per agent (prompt-layers.ts). Unset where the preset
34
+ * does not say the parameter is understood. */
35
+ cacheKey?: string;
31
36
  /** How the endpoint is named in the trace. */
32
37
  label?: string;
33
38
  /** The step's egress lease, when the sandbox must not hold the key: the
@@ -77,6 +82,7 @@ export declare function toChatCompletions(req: Json, opts: {
77
82
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
78
83
  reasoningEffort?: boolean;
79
84
  stream: boolean;
85
+ cacheKey?: string;
80
86
  /** Merged in last, so the file wins over the translation. */
81
87
  params?: Record<string, unknown>;
82
88
  }, drop?: Dropped): Json;
@@ -93,6 +99,7 @@ export declare function toChatCompletions(req: Json, opts: {
93
99
  export declare function toResponses(req: Json, opts: {
94
100
  reasoningEffort?: boolean;
95
101
  stream: boolean;
102
+ cacheKey?: string;
96
103
  params?: Record<string, unknown>;
97
104
  }, drop?: Dropped): Json;
98
105
  /** A finished Chat Completion as an Anthropic message. Pure. */
@@ -173,5 +180,6 @@ export declare function translatorSpecFor(spec: {
173
180
  name?: string | null;
174
181
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
175
182
  reasoningEffort?: boolean;
183
+ cacheKey?: string;
176
184
  }): TranslatorSpec | null;
177
185
  export {};
@@ -123,6 +123,8 @@ function imagePart(block) {
123
123
  */
124
124
  export function toChatCompletions(req, opts, drop = dropped()) {
125
125
  const out = { model: req.model, stream: opts.stream };
126
+ if (opts.cacheKey)
127
+ out.prompt_cache_key = opts.cacheKey;
126
128
  const messages = [];
127
129
  const system = textOf(req.system);
128
130
  if (system)
@@ -285,6 +287,8 @@ export function toChatCompletions(req, opts, drop = dropped()) {
285
287
  */
286
288
  export function toResponses(req, opts, drop = dropped()) {
287
289
  const out = { model: req.model, stream: opts.stream, store: false };
290
+ if (opts.cacheKey)
291
+ out.prompt_cache_key = opts.cacheKey;
288
292
  const system = textOf(req.system);
289
293
  if (system)
290
294
  out.instructions = system;
@@ -904,8 +908,8 @@ export async function startTranslator(spec) {
904
908
  const drop = dropped();
905
909
  const responses = spec.wire === "responses";
906
910
  const outbound = responses
907
- ? toResponses(body, { reasoningEffort: spec.reasoningEffort, params: spec.params, stream }, drop)
908
- : toChatCompletions(body, { maxTokensParam: spec.maxTokensParam, reasoningEffort: spec.reasoningEffort, params: spec.params, stream }, drop);
911
+ ? toResponses(body, { reasoningEffort: spec.reasoningEffort, params: spec.params, cacheKey: spec.cacheKey, stream }, drop)
912
+ : toChatCompletions(body, { maxTokensParam: spec.maxTokensParam, reasoningEffort: spec.reasoningEffort, params: spec.params, cacheKey: spec.cacheKey, stream }, drop);
909
913
  for (const line of drop.lines())
910
914
  if (!log.includes(line))
911
915
  log.push(line);
@@ -1051,6 +1055,7 @@ export function translatorSpecFor(spec) {
1051
1055
  params: spec.params,
1052
1056
  maxTokensParam: spec.maxTokensParam,
1053
1057
  reasoningEffort: spec.reasoningEffort,
1058
+ ...(spec.cacheKey ? { cacheKey: spec.cacheKey } : {}),
1054
1059
  label: spec.name ? `${spec.name} (${spec.baseUrl})` : spec.baseUrl,
1055
1060
  };
1056
1061
  }