@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,109 @@
1
+ // Frontmatter edited as text, where it is written: at the top or inside one
2
+ // of the four job blocks. No node imports — the flow canvas (flow-patterns.ts)
3
+ // runs in the browser and edits frontmatter with it too.
4
+
5
+ /** A block header on its own line — an anchor and a trailing `# comment`
6
+ * allowed. */
7
+ const JOB_BLOCK = /^["']?(governance|guardrail|observability|evaluation)["']?\s*:\s*(&\S+\s*)?(#.*)?$/;
8
+ /** Any other block header: an inline map, an alias, a tag — a form these
9
+ * writers do not edit. */
10
+ const JOB_OTHER = /^["']?(governance|guardrail|observability|evaluation)["']?\s*:\s*\S/;
11
+ /** An inline map closed on its own line: `guardrail: {network: [a.com]}`. */
12
+ const JOB_ONE_LINE = /^["']?(governance|guardrail|observability|evaluation)["']?\s*:\s*\{.*\}\s*(#.*)?$/;
13
+
14
+ const indentOf = (l: string) => l.match(/^[ \t]*/)?.[0].length ?? 0;
15
+
16
+ export interface KeySpan {
17
+ /** The key's line. */
18
+ at: number;
19
+ /** One past its last line. */
20
+ end: number;
21
+ /** What its line starts with. */
22
+ indent: string;
23
+ /** The block header's line, when it is inside one. */
24
+ block: number | null;
25
+ }
26
+
27
+ /** Thrown for a key a writer cannot edit in place: one that is, or may be,
28
+ * inside a block not written one key per line (`guardrail: {…}`, an alias).
29
+ * Rewriting it would mean re-serialising the person's YAML, and adding the
30
+ * key at the top beside it would be a config/conflict — so the writer says
31
+ * what to write instead. */
32
+ export class InlineBlockError extends Error {
33
+ status = 409;
34
+ constructor(key: string, block: string) {
35
+ super(`\`${key}\` may be inside \`${block}:\`, which is not written one key per line — write \`${block}:\` one key per line to change it here, or edit the file`);
36
+ }
37
+ }
38
+
39
+ /** A comment or blank line: neither ends a block nor is one of its keys. */
40
+ const filler = (l: string) => l.trim() === "" || /^\s*#/.test(l);
41
+
42
+ /**
43
+ * Where a key is written in frontmatter lines: at the top, or a direct child
44
+ * of one of the four job blocks (`guardrail:` and so on). Every writer that
45
+ * edits a key as text reads it here, so a file written in blocks is edited
46
+ * where it is — never given a second, flat copy that check would refuse as
47
+ * config/conflict. A key inside an inline block map throws InlineBlockError.
48
+ */
49
+ export function keySpan(lines: string[], key: string): KeySpan | null {
50
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ const span = (at: number, indent: string, block: number | null): KeySpan => {
52
+ let end = at + 1;
53
+ // The key continues while lines are indented deeper, are an item of its
54
+ // list written at its own indent (YAML allows `key:\n- a`), or are blank
55
+ // lines and comments inside it.
56
+ while (
57
+ end < lines.length &&
58
+ (filler(lines[end]) ||
59
+ indentOf(lines[end]) > indent.length ||
60
+ (indentOf(lines[end]) === indent.length && /^[ \t]*-(\s|$)/.test(lines[end])))
61
+ ) end++;
62
+ // Blank lines and comments at its foot, no deeper than the key, belong
63
+ // to what follows.
64
+ while (end > at + 1 && filler(lines[end - 1]) && (lines[end - 1].trim() === "" || indentOf(lines[end - 1]) <= indent.length)) end--;
65
+ return { at, end, indent, block };
66
+ };
67
+ // A key may be quoted: `"network": none`.
68
+ const named = `["']?${k}["']?\\s*:`;
69
+ const top = lines.findIndex((l) => new RegExp(`^${named}`).test(l));
70
+ if (top !== -1) return span(top, "", null);
71
+ for (let h = 0; h < lines.length; h++) {
72
+ const other = !JOB_BLOCK.test(lines[h]) && JOB_OTHER.exec(lines[h]);
73
+ if (other) {
74
+ // One closed line can be read: refuse only if the key is in it. Any
75
+ // other form (a map over several lines, an alias) cannot be, so any
76
+ // key not found at the top is refused rather than added beside it.
77
+ if (!JOB_ONE_LINE.test(lines[h]) || new RegExp(`[{,]\\s*${named}`).test(lines[h])) throw new InlineBlockError(key, other[1]!);
78
+ continue;
79
+ }
80
+ if (!JOB_BLOCK.test(lines[h])) continue;
81
+ // Its children sit at the indent of its first indented line; a deeper
82
+ // line of the same name belongs to one of them, not to the block.
83
+ // Comments and blank lines, indented or not, do not end it.
84
+ let child = -1;
85
+ let merged = false;
86
+ for (let n = h + 1; n < lines.length && (/^[ \t]/.test(lines[n]) || filler(lines[n])); n++) {
87
+ if (filler(lines[n])) continue;
88
+ if (child === -1) child = indentOf(lines[n]);
89
+ if (indentOf(lines[n]) !== child) continue;
90
+ const m = new RegExp(`^([ \t]+)${named}`).exec(lines[n]);
91
+ if (m) return span(n, m[1]!, h);
92
+ if (/^[ \t]+<<\s*:/.test(lines[n])) merged = true;
93
+ }
94
+ // `<<: *policy` may bring the key in from elsewhere: not one this text
95
+ // edit can see, so it is refused rather than added beside it.
96
+ if (merged) throw new InlineBlockError(key, JOB_BLOCK.exec(lines[h])![1]!);
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /** A block header left with nothing under it is removed with its last key.
102
+ * Blank lines and comments between it and its next key do not count as
103
+ * nothing: the header stays while any indented key follows them. */
104
+ export function dropEmptyBlock(lines: string[], header: number): string[] {
105
+ let n = header + 1;
106
+ while (n < lines.length && filler(lines[n])) n++;
107
+ const next = lines[n];
108
+ return next === undefined || !/^[ \t]+\S/.test(next) ? [...lines.slice(0, header), ...lines.slice(header + 1)] : lines;
109
+ }
package/src/history.ts CHANGED
@@ -402,6 +402,19 @@ export function readRevision(tenant: string, scope: string, id: string, forPath?
402
402
  return forPath ? { ...rev, files: rev.files.filter((f) => f.path === forPath) } : rev;
403
403
  }
404
404
 
405
+ /** The files changed after revision `from` up to and including `to`, in a
406
+ * scope — what differs between the versions two runs ran (FOL-72). Null
407
+ * when either is unknown or `to` is not after `from`. */
408
+ export function changedPathsBetween(tenant: string, scope: string, from: string | null, to: string | null): string[] | null {
409
+ if (!from || !to) return null;
410
+ if (from === to) return [];
411
+ const revs = listRevisions(tenant, scope, { limit: 500 }); // newest first
412
+ const ti = revs.findIndex((r) => r.id === to || r.commit === to);
413
+ const fi = revs.findIndex((r) => r.id === from || r.commit === from);
414
+ if (ti < 0 || fi < 0 || ti > fi) return null;
415
+ return [...new Set(revs.slice(ti, fi).flatMap((r) => r.paths))].sort();
416
+ }
417
+
405
418
  /** The most recent revision id for a scope — what a change is attributed
406
419
  * to when nothing more specific (a deployed commit) is on record. */
407
420
  export function latestRevisionId(tenant: string, scope: string): string | null {
package/src/hooks.ts CHANGED
@@ -31,7 +31,7 @@ import fs from "node:fs";
31
31
  import path from "node:path";
32
32
  import os from "node:os";
33
33
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
34
- import matter from "gray-matter";
34
+ import matter from "./matter.ts";
35
35
 
36
36
  export const HOOK_EVENTS = ["tool.call", "tool.result", "step.start", "step.end"] as const;
37
37
  export type HookEvent = (typeof HOOK_EVENTS)[number];
@@ -0,0 +1,174 @@
1
+ // Insights (FOL-72 step 8): the problems that recur without failing anything,
2
+ // found in the runs with no setup — each with its evidence, what it costs and
3
+ // what to do. The operator's "3 problems, here is why" instead of a list of
4
+ // runs. Pure: the API, the Observe page and `foldrun insights` read the same.
5
+ //
6
+ // cache an agent re-reads a big prompt uncached, step after step
7
+ // big-result a tool's result is large and every later model call sends it again
8
+ // retry-loop a tool errors again and again inside one step
9
+ // timeout steps that use most of their `timeout:` — the next bigger input fails
10
+ //
11
+ // Costs are estimates in credits from the steps' own token costs (a credit
12
+ // is a cent of USD), said as "about".
13
+
14
+ import type { RunRecord, StepRecord } from "./store.ts";
15
+
16
+ export type InsightKind = "cache" | "big-result" | "retry-loop" | "timeout";
17
+
18
+ export interface Insight {
19
+ id: string;
20
+ kind: InsightKind;
21
+ workspace: string;
22
+ agent: string;
23
+ tool?: string;
24
+ title: string;
25
+ /** Why it happens, in a sentence. */
26
+ why: string;
27
+ fix: string;
28
+ /** About how many credits it cost in the window; null when not money. */
29
+ credits: number | null;
30
+ /** How many steps showed it. */
31
+ steps: number;
32
+ /** The newest runs it was seen in, at most 5. */
33
+ evidence: { workspace: string; runId: string; step: number }[];
34
+ }
35
+
36
+ type Run = RunRecord & { workspace: string };
37
+
38
+ /** A cached input token costs about a tenth of a fresh one. */
39
+ const CACHE_SAVING = 0.9;
40
+ /** Characters to tokens, roughly, for text in a conversation. */
41
+ const CHARS_PER_TOKEN = 4;
42
+ const BIG_RESULT = 20_000;
43
+
44
+ const stepRefs = (hits: { run: Run; index: number }[]) =>
45
+ [...hits].sort((a, b) => b.run.startedAt.localeCompare(a.run.startedAt)).slice(0, 5).map((h) => ({ workspace: h.run.workspace, runId: h.run.id, step: h.index + 1 }));
46
+
47
+ const credits = (usd: number) => Math.round(usd * 100);
48
+
49
+ export function findInsights(runs: Run[], opts: { minSteps?: number } = {}): Insight[] {
50
+ const min = opts.minSteps ?? 3;
51
+ const steps: { run: Run; index: number; s: StepRecord }[] = [];
52
+ for (const run of runs) run.steps.forEach((s, index) => {
53
+ if (s.status === "completed" || s.status === "failed") steps.push({ run, index, s });
54
+ });
55
+ const out: Insight[] = [];
56
+ const byAgent = new Map<string, typeof steps>();
57
+ for (const x of steps) {
58
+ const k = `${x.run.workspace}\u0000${x.s.agent}`;
59
+ (byAgent.get(k) ?? byAgent.set(k, []).get(k)!).push(x);
60
+ }
61
+
62
+ for (const [k, list] of byAgent) {
63
+ const [workspace, agent] = k.split("\u0000") as [string, string];
64
+
65
+ // cache: big inputs, little of it from the cache, on most of its steps.
66
+ const measured = list.filter((x) => x.s.tokens && x.s.tokens.cacheRead !== undefined && x.s.tokens.input >= 20_000);
67
+ const poor = measured.filter((x) => (x.s.tokens!.cacheRead ?? 0) / x.s.tokens!.input < 0.3);
68
+ if (poor.length >= min && poor.length >= measured.length / 2) {
69
+ const usd = poor.reduce((n, x) => {
70
+ const t = x.s.tokens!;
71
+ const uncached = Math.max(0, t.input - (t.cacheRead ?? 0)) / Math.max(1, t.input);
72
+ return n + (x.s.costUsd ?? 0) * uncached * CACHE_SAVING * 0.5;
73
+ }, 0);
74
+ const rate = Math.round((100 * poor.reduce((n, x) => n + (x.s.tokens!.cacheRead ?? 0), 0)) / Math.max(1, poor.reduce((n, x) => n + x.s.tokens!.input, 0)));
75
+ out.push({
76
+ id: `cache/${workspace}/${agent}`,
77
+ kind: "cache",
78
+ workspace,
79
+ agent,
80
+ title: `${agent} reads its prompt mostly uncached (${rate}% from the cache)`,
81
+ why: "Each call sends a large prompt that the model's cache rarely serves: something near its start changes from call to call (a date, a run id, a file listing), or the steps are spaced further apart than the cache lasts.",
82
+ fix: "Keep what changes (dates, ids, the task) at the end of the agent's prompt and the large fixed part first; run its steps closer together.",
83
+ credits: credits(usd),
84
+ steps: poor.length,
85
+ evidence: stepRefs(poor),
86
+ });
87
+ }
88
+
89
+ // big-result: a tool's result over 20k characters with calls after it,
90
+ // each of which sends it again.
91
+ const big = new Map<string, { hits: typeof list; usd: number; chars: number }>();
92
+ for (const x of list) {
93
+ const callsAfter = (at: string) => (x.s.calls ?? []).filter((c) => c.at > at).length;
94
+ const turns = x.s.calls?.length ?? x.s.turns ?? 0;
95
+ for (const e of x.s.events) {
96
+ if (e.type !== "tool" || e.ms == null || (e.bytes ?? 0) < BIG_RESULT) continue;
97
+ const after = x.s.calls ? callsAfter(e.t) : Math.max(0, turns - 1);
98
+ if (after < 2) continue;
99
+ const tokensResent = ((e.bytes ?? 0) / CHARS_PER_TOKEN) * after;
100
+ const input = Math.max(1, x.s.tokens?.input ?? 0);
101
+ const share = Math.min(1, tokensResent / input);
102
+ const b = big.get(e.text) ?? { hits: [], usd: 0, chars: 0 };
103
+ if (!b.hits.includes(x)) b.hits.push(x);
104
+ b.usd += (x.s.costUsd ?? 0) * share;
105
+ b.chars = Math.max(b.chars, e.bytes ?? 0);
106
+ big.set(e.text, b);
107
+ }
108
+ }
109
+ for (const [tool, b] of big) {
110
+ if (b.hits.length < min) continue;
111
+ out.push({
112
+ id: `big-result/${workspace}/${agent}/${tool}`,
113
+ kind: "big-result",
114
+ workspace,
115
+ agent,
116
+ tool,
117
+ title: `${tool} hands ${agent} results up to ${Math.round(b.chars / 1000)}k characters, sent again on every later call`,
118
+ why: "A tool result stays in the conversation, so every model call after it pays for it again.",
119
+ fix: `Have ${tool} return less (filter, a summary, or write the bulk to a file and return the path), or split the step so the big read ends it.`,
120
+ credits: credits(b.usd),
121
+ steps: b.hits.length,
122
+ evidence: stepRefs(b.hits),
123
+ });
124
+ }
125
+
126
+ // retry-loop: one tool erroring three or more times in a step.
127
+ const loops = new Map<string, typeof list>();
128
+ for (const x of list) {
129
+ const errs = new Map<string, number>();
130
+ for (const e of x.s.events) if (e.type === "tool" && e.ms != null && e.err) errs.set(e.text, (errs.get(e.text) ?? 0) + 1);
131
+ for (const [tool, n] of errs) if (n >= 3) (loops.get(tool) ?? loops.set(tool, []).get(tool)!).push(x);
132
+ }
133
+ for (const [tool, hits] of loops) {
134
+ if (hits.length < Math.min(min, 2)) continue;
135
+ out.push({
136
+ id: `retry-loop/${workspace}/${agent}/${tool}`,
137
+ kind: "retry-loop",
138
+ workspace,
139
+ agent,
140
+ tool,
141
+ title: `${agent} keeps retrying ${tool} after it errors`,
142
+ why: "The tool fails the same way and the agent tries again instead of stopping — every attempt is a model call and a tool call.",
143
+ fix: `Read one of the errors (the trace's input / output): fix the tool or its input, or tell ${agent} in its prompt to stop after one failure and say BLOCKED with why.`,
144
+ credits: credits(hits.reduce((n, x) => n + (x.s.costUsd ?? 0) * 0.3, 0)),
145
+ steps: hits.length,
146
+ evidence: stepRefs(hits),
147
+ });
148
+ }
149
+
150
+ // timeout: steps that took 80% or more of their timeout.
151
+ const tight = list.filter((x) => {
152
+ if (!x.s.timeout || !x.s.startedAt || !x.s.finishedAt) return false;
153
+ const secs = (Date.parse(x.s.finishedAt) - Date.parse(x.s.startedAt)) / 1000;
154
+ return secs >= 0.8 * x.s.timeout;
155
+ });
156
+ if (tight.length >= Math.min(min, 2)) {
157
+ const limit = tight[0]!.s.timeout!;
158
+ out.push({
159
+ id: `timeout/${workspace}/${agent}`,
160
+ kind: "timeout",
161
+ workspace,
162
+ agent,
163
+ title: `${agent} uses most of its ${limit}s timeout`,
164
+ why: "Steps finish just inside their limit; a slightly bigger input or a slow site and they are cut off.",
165
+ fix: "Raise timeout: on the step, or give the agent less to do per step (each: to fan out, a smaller batch).",
166
+ credits: null,
167
+ steps: tight.length,
168
+ evidence: stepRefs(tight),
169
+ });
170
+ }
171
+ }
172
+ const order: Record<InsightKind, number> = { "retry-loop": 0, timeout: 1, "big-result": 2, cache: 3 };
173
+ return out.sort((a, b) => (b.credits ?? -1) - (a.credits ?? -1) || order[a.kind] - order[b.kind]);
174
+ }