@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.
- package/dist/src/agent-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- package/dist/src/agent-import.js +1 -1
- package/dist/src/agent-tools.js +1 -1
- package/dist/src/agents-md.js +1 -1
- package/dist/src/alerts.d.ts +60 -0
- package/dist/src/alerts.js +161 -0
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/capture.d.ts +23 -0
- package/dist/src/capture.js +80 -0
- package/dist/src/compare.d.ts +57 -0
- package/dist/src/compare.js +116 -0
- package/dist/src/completions.js +1 -0
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +170 -11
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +5 -1
- package/dist/src/evals.js +20 -6
- package/dist/src/frontmatter-fields.js +9 -0
- package/dist/src/frontmatter-schema.js +3 -0
- package/dist/src/history.d.ts +4 -0
- package/dist/src/history.js +15 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/insights.d.ts +30 -0
- package/dist/src/insights.js +152 -0
- package/dist/src/issues.d.ts +140 -0
- package/dist/src/issues.js +302 -0
- package/dist/src/library.js +1 -1
- package/dist/src/limits.d.ts +8 -0
- package/dist/src/limits.js +36 -5
- package/dist/src/linkable.js +1 -1
- package/dist/src/matter.d.ts +8 -0
- package/dist/src/matter.js +28 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/network.d.ts +12 -0
- package/dist/src/network.js +25 -1
- package/dist/src/notify.d.ts +16 -0
- package/dist/src/notify.js +17 -7
- package/dist/src/okf.js +1 -1
- package/dist/src/online-checks.d.ts +76 -0
- package/dist/src/online-checks.js +432 -0
- package/dist/src/operator.d.ts +277 -0
- package/dist/src/operator.js +176 -2
- package/dist/src/otel-config.d.ts +18 -0
- package/dist/src/otel-config.js +69 -0
- package/dist/src/otel.d.ts +38 -0
- package/dist/src/otel.js +85 -0
- package/dist/src/outcome.d.ts +40 -0
- package/dist/src/outcome.js +77 -0
- package/dist/src/package.js +1 -1
- package/dist/src/platform.d.ts +1 -1
- package/dist/src/prompt-layers.d.ts +30 -0
- package/dist/src/prompt-layers.js +77 -0
- package/dist/src/provider-health.js +1 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +16 -2
- package/dist/src/run-container.js +57 -5
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +356 -70
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +19 -1
- package/dist/src/scan.js +60 -7
- package/dist/src/starter.js +4 -2
- package/dist/src/step-exec.d.ts +29 -1
- package/dist/src/step-exec.js +106 -6
- package/dist/src/store.d.ts +130 -3
- package/dist/src/store.js +145 -6
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +57 -0
- package/dist/src/tool-names.js +1 -1
- package/dist/src/tool-programs.js +1 -1
- package/dist/src/trace-tree.d.ts +40 -0
- package/dist/src/trace-tree.js +156 -0
- package/dist/src/translator.d.ts +8 -0
- package/dist/src/translator.js +7 -2
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/workspace-check.d.ts +18 -0
- package/dist/src/workspace-check.js +34 -0
- package/package.json +2 -2
- package/src/agent-activity.ts +32 -0
- package/src/agent-import.ts +1 -1
- package/src/agent-tools.ts +1 -1
- package/src/agents-md.ts +1 -1
- package/src/alerts.ts +190 -0
- package/src/browser-pod.ts +3 -0
- package/src/capture.ts +88 -0
- package/src/compare.ts +146 -0
- package/src/completions.ts +1 -0
- package/src/deploy.ts +184 -10
- package/src/egress.ts +12 -2
- package/src/evals.ts +19 -6
- package/src/frontmatter-fields.ts +9 -0
- package/src/frontmatter-schema.ts +3 -0
- package/src/history.ts +13 -0
- package/src/hooks.ts +1 -1
- package/src/insights.ts +174 -0
- package/src/issues.ts +396 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +33 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +25 -1
- package/src/notify.ts +22 -7
- package/src/okf.ts +1 -1
- package/src/online-checks.ts +450 -0
- package/src/operator.ts +213 -3
- package/src/otel-config.ts +67 -0
- package/src/otel.ts +100 -0
- package/src/outcome.ts +90 -0
- package/src/package.ts +1 -1
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +1 -1
- package/src/providers.ts +7 -2
- package/src/run-container.ts +66 -7
- package/src/runner.ts +363 -46
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +68 -7
- package/src/starter.ts +4 -2
- package/src/step-exec.ts +147 -9
- package/src/store.ts +254 -8
- package/src/tokens.ts +68 -0
- package/src/tool-names.ts +1 -1
- package/src/tool-programs.ts +1 -1
- package/src/trace-tree.ts +187 -0
- package/src/translator.ts +13 -2
- package/src/trigger-log.ts +1 -0
- package/src/workspace-check.ts +41 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Search over runs (FOL-72 step 9): one filter for the runs API, the runs
|
|
2
|
+
// pages and `foldrun runs`, read off the run index — the tools each step
|
|
3
|
+
// called, its tool errors, its agent and model, tokens and how long it took.
|
|
4
|
+
// Pure.
|
|
5
|
+
|
|
6
|
+
export interface RunFilter {
|
|
7
|
+
/** A step called a tool whose name contains this (`web`, `crm_export`). */
|
|
8
|
+
tool?: string;
|
|
9
|
+
/** A tool call errored. */
|
|
10
|
+
toolError?: boolean;
|
|
11
|
+
/** A step was this agent. */
|
|
12
|
+
agent?: string;
|
|
13
|
+
/** A step ran a model whose name contains this (`haiku`, `opus`). */
|
|
14
|
+
model?: string;
|
|
15
|
+
/** The run read at least this many input tokens, all steps together. */
|
|
16
|
+
minTokens?: number;
|
|
17
|
+
/** The run took at least this many seconds. */
|
|
18
|
+
minSecs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The query parameters a filter comes from: tool, tool_error, agent,
|
|
22
|
+
* model, min_tokens, min_secs. Unreadable numbers are refused in words. */
|
|
23
|
+
export function parseRunFilter(q: { get(name: string): string | null }): { filter: RunFilter; problems: string[] } {
|
|
24
|
+
const filter: RunFilter = {};
|
|
25
|
+
const problems: string[] = [];
|
|
26
|
+
const text = (k: string) => q.get(k)?.trim() || undefined;
|
|
27
|
+
const tool = text("tool");
|
|
28
|
+
if (tool) filter.tool = tool.toLowerCase();
|
|
29
|
+
const te = text("tool_error");
|
|
30
|
+
if (te) filter.toolError = te === "1" || te === "true" || te === "yes";
|
|
31
|
+
const agent = text("agent");
|
|
32
|
+
if (agent) filter.agent = agent;
|
|
33
|
+
const model = text("model");
|
|
34
|
+
if (model) filter.model = model.toLowerCase();
|
|
35
|
+
for (const [k, field] of [["min_tokens", "minTokens"], ["min_secs", "minSecs"]] as const) {
|
|
36
|
+
const v = text(k);
|
|
37
|
+
if (v === undefined) continue;
|
|
38
|
+
// "50k" and "1.5k" are thousands (Codex review: "1.5k" read as 1.5).
|
|
39
|
+
const kilo = /^(\d+(?:\.\d+)?)k$/i.exec(v);
|
|
40
|
+
const n = kilo ? Number(kilo[1]) * 1000 : Number(v);
|
|
41
|
+
if (!Number.isFinite(n) || n < 0) problems.push(`${k} is a number (${k === "min_tokens" ? "50000 or 50k" : "seconds"}), not ${v}`);
|
|
42
|
+
else filter[field] = n;
|
|
43
|
+
}
|
|
44
|
+
return { filter, problems };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const isEmptyFilter = (f: RunFilter) => Object.keys(f).length === 0;
|
|
48
|
+
|
|
49
|
+
type FilterableStep = {
|
|
50
|
+
agent: string;
|
|
51
|
+
model?: string;
|
|
52
|
+
tools?: string[];
|
|
53
|
+
toolErrors?: number;
|
|
54
|
+
/** A whole record has events instead of the index's tools/toolErrors. */
|
|
55
|
+
events?: { type: string; text: string; ms?: number; err?: boolean }[];
|
|
56
|
+
tokens?: { input?: number } | null;
|
|
57
|
+
startedAt?: string;
|
|
58
|
+
finishedAt?: string;
|
|
59
|
+
};
|
|
60
|
+
type FilterableRun = { startedAt: string; finishedAt?: string | null; steps: FilterableStep[] };
|
|
61
|
+
|
|
62
|
+
/** The index's facts, or worked out from a whole record's events (Codex
|
|
63
|
+
* review: the workspace runs page reads whole records). */
|
|
64
|
+
function factsOf(s: FilterableStep): { tools: string[]; toolErrors: number } {
|
|
65
|
+
if (s.tools !== undefined || s.toolErrors !== undefined || !s.events) return { tools: s.tools ?? [], toolErrors: s.toolErrors ?? 0 };
|
|
66
|
+
const tools = new Set<string>();
|
|
67
|
+
let errs = 0;
|
|
68
|
+
for (const e of s.events) {
|
|
69
|
+
if (e.type !== "tool") continue;
|
|
70
|
+
if (e.ms == null) tools.add(e.text);
|
|
71
|
+
else if (e.err) errs++;
|
|
72
|
+
}
|
|
73
|
+
return { tools: [...tools], toolErrors: errs };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function matchesRunFilter(run: FilterableRun, f: RunFilter, now = Date.now()): boolean {
|
|
77
|
+
const steps = (run.steps ?? []).map((s) => ({ ...s, ...factsOf(s) }));
|
|
78
|
+
if (f.tool && !steps.some((s) => (s.tools ?? []).some((t) => t.toLowerCase().includes(f.tool!)))) return false;
|
|
79
|
+
if (f.toolError !== undefined && steps.some((s) => (s.toolErrors ?? 0) > 0) !== f.toolError) return false;
|
|
80
|
+
if (f.agent && !steps.some((s) => s.agent === f.agent)) return false;
|
|
81
|
+
if (f.model && !steps.some((s) => (s.model ?? "").toLowerCase().includes(f.model!))) return false;
|
|
82
|
+
if (f.minTokens !== undefined && steps.reduce((n, s) => n + (s.tokens?.input ?? 0), 0) < f.minTokens) return false;
|
|
83
|
+
if (f.minSecs !== undefined) {
|
|
84
|
+
const secs = ((run.finishedAt ? Date.parse(run.finishedAt) : now) - Date.parse(run.startedAt)) / 1000;
|
|
85
|
+
if (!(secs >= f.minSecs)) return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
package/src/scan.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// reported, because "why is this allowed?" is the question a reviewer asks.
|
|
28
28
|
|
|
29
29
|
import crypto from "node:crypto";
|
|
30
|
-
import matter from "
|
|
30
|
+
import matter from "./matter.ts";
|
|
31
31
|
import { refNames } from "./refs.ts";
|
|
32
32
|
import { resolveNetwork } from "./network.ts";
|
|
33
33
|
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
|
|
@@ -62,6 +62,10 @@ export interface ScanFinding {
|
|
|
62
62
|
message: string;
|
|
63
63
|
/** Stable across edits elsewhere in the file: rule + file + line text. */
|
|
64
64
|
fingerprint: string;
|
|
65
|
+
/** This rule had more to say in this file than PER_FILE_RULE: this
|
|
66
|
+
* finding counts the rest, so what is not listed is not resolved. Its
|
|
67
|
+
* fingerprint is of everything it counts, so a change among them is new. */
|
|
68
|
+
truncated?: true;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
export interface ScanOptions {
|
|
@@ -173,6 +177,26 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
173
177
|
"so a run tomorrow uses what was tested today.",
|
|
174
178
|
owasp: ["ASI04"],
|
|
175
179
|
},
|
|
180
|
+
{
|
|
181
|
+
id: "supply-chain/known-vulnerability",
|
|
182
|
+
category: "supply-chain",
|
|
183
|
+
level: "warn",
|
|
184
|
+
title: "A pinned runtime package with known vulnerabilities",
|
|
185
|
+
help:
|
|
186
|
+
"OSV.dev lists vulnerabilities for this exact version. Upgrade to a fixed one. An online check: `foldrun check --to <ws> --online`, " +
|
|
187
|
+
"Checks → Check online, and the daily rescan.",
|
|
188
|
+
owasp: ["ASI04"],
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "content/mcp-tool-text",
|
|
192
|
+
category: "content",
|
|
193
|
+
level: "warn",
|
|
194
|
+
title: "An MCP server's tool text carries hidden or injected instructions",
|
|
195
|
+
help:
|
|
196
|
+
"What a remote MCP server says about its tools reaches the model as instructions. Hidden characters or an order in a tool " +
|
|
197
|
+
"description is a server steering your agent. An online check, for servers reached by URL; a command server is never run to check it.",
|
|
198
|
+
owasp: ["ASI01", "ASI04"],
|
|
199
|
+
},
|
|
176
200
|
{
|
|
177
201
|
id: "config/unknown-field",
|
|
178
202
|
category: "config",
|
|
@@ -451,13 +475,15 @@ const INJECTION: RegExp[] = [
|
|
|
451
475
|
|
|
452
476
|
const BLOB = /[A-Za-z0-9+/]{300,}={0,2}/g;
|
|
453
477
|
|
|
454
|
-
function scanContent(file: ScanFile, lines: string[], push: Push) {
|
|
455
|
-
|
|
456
|
-
|
|
478
|
+
function scanContent(file: ScanFile, lines: string[], push: Push, untrusted = false) {
|
|
479
|
+
// Untrusted text (a remote server's) is read as data, every line: it
|
|
480
|
+
// cannot fence an order off as an "example" either.
|
|
481
|
+
const md = untrusted || /\.md$/i.test(file.path);
|
|
482
|
+
const data = untrusted || isDataDoc(file.path);
|
|
457
483
|
let fenced = false;
|
|
458
484
|
for (let i = 0; i < lines.length; i++) {
|
|
459
485
|
const line = i === 0 ? lines[i]!.replace(/^/, "") : lines[i]!;
|
|
460
|
-
if (/^\s*(?:```|~~~)/.test(line)) fenced = !fenced;
|
|
486
|
+
if (!untrusted && /^\s*(?:```|~~~)/.test(line)) fenced = !fenced;
|
|
461
487
|
HIDDEN.lastIndex = 0;
|
|
462
488
|
if (HIDDEN.test(line)) {
|
|
463
489
|
const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
|
|
@@ -481,6 +507,18 @@ function scanContent(file: ScanFile, lines: string[], push: Push) {
|
|
|
481
507
|
}
|
|
482
508
|
}
|
|
483
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Text from outside the workspace — what a remote MCP server says about its
|
|
512
|
+
* tools — through the content rules with nothing the text itself can turn
|
|
513
|
+
* off: no foldrun-ignore, no binary or vendored-path skip, no fences
|
|
514
|
+
* (FOL-58, Codex review: each of those let a server silence its own finding).
|
|
515
|
+
*/
|
|
516
|
+
export function untrustedTextFindings(text: string): { rule: string; line: number; message: string }[] {
|
|
517
|
+
const out: { rule: string; line: number; message: string }[] = [];
|
|
518
|
+
scanContent({ path: "untrusted", content: text }, text.split(/\r?\n/), (rule, _file, line, _text, message) => out.push({ rule, line: line ?? 0, message }), true);
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
484
522
|
/** The text hidden in tag characters: each is U+E0000 + an ASCII code. */
|
|
485
523
|
function decodeTags(s: string): string {
|
|
486
524
|
let out = "";
|
|
@@ -742,9 +780,13 @@ function isText(f: ScanFile): boolean {
|
|
|
742
780
|
return !VENDORED.test(f.path) && !f.content.slice(0, 8000).includes("\u0000") && !/\.(?:png|jpe?g|gif|webp|ico|pdf|zip|gz|woff2?|ttf|mp[34]|mov|wasm)$/i.test(f.path);
|
|
743
781
|
}
|
|
744
782
|
|
|
783
|
+
/** How many times one rule is listed for one file before the rest are counted. */
|
|
784
|
+
export const PER_FILE_RULE = 100;
|
|
785
|
+
|
|
745
786
|
/**
|
|
746
787
|
* Every finding in these files, ignores applied. Findings are sorted by file
|
|
747
|
-
* then line, and one rule says one thing once per line
|
|
788
|
+
* then line, and one rule says one thing once per line, and at most
|
|
789
|
+
* PER_FILE_RULE times in one file.
|
|
748
790
|
*/
|
|
749
791
|
export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFinding[] {
|
|
750
792
|
const want = options.only?.length ? new Set(options.only) : null;
|
|
@@ -752,6 +794,11 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
752
794
|
const findings: ScanFinding[] = [];
|
|
753
795
|
const seen = new Set<string>();
|
|
754
796
|
const ignoresByFile = new Map<string, Ignores>();
|
|
797
|
+
// One rule in one file says it PER_FILE_RULE times, then counts the rest:
|
|
798
|
+
// a million lines of hidden characters is one problem, not a million
|
|
799
|
+
// findings held in memory (Codex review of the 20 Oct rescan outage).
|
|
800
|
+
const perFileRule = new Map<string, number>();
|
|
801
|
+
const more = new Map<string, { rule: string; file: string; level?: ScanFinding["level"]; n: number; hash: crypto.Hash }>();
|
|
755
802
|
|
|
756
803
|
const push: Push = (rule, file, line, text, message, level) => {
|
|
757
804
|
if (!enabled(rule)) return;
|
|
@@ -759,6 +806,16 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
759
806
|
if (ig && ignored(ig, rule, line)) return;
|
|
760
807
|
const key = `${rule}\0${file}\0${line ?? ""}\0${message}`;
|
|
761
808
|
if (seen.has(key)) return;
|
|
809
|
+
const rf = `${rule}\0${file}`;
|
|
810
|
+
const said = perFileRule.get(rf) ?? 0;
|
|
811
|
+
if (said >= PER_FILE_RULE) {
|
|
812
|
+
const m = more.get(rf) ?? { rule, file, level, n: 0, hash: crypto.createHash("sha256") };
|
|
813
|
+
m.n++;
|
|
814
|
+
m.hash.update(`${findingFingerprint(rule, file, text)}\0`);
|
|
815
|
+
more.set(rf, m);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
perFileRule.set(rf, said + 1);
|
|
762
819
|
seen.add(key);
|
|
763
820
|
findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
|
|
764
821
|
};
|
|
@@ -800,8 +857,12 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
800
857
|
toolDefs.set(f.path, { data, lines });
|
|
801
858
|
}
|
|
802
859
|
}
|
|
803
|
-
|
|
860
|
+
// project.md is the older name the runtime still reads (Codex review, FOL-58).
|
|
861
|
+
const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
|
|
804
862
|
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
|
|
863
|
+
for (const m of more.values()) {
|
|
864
|
+
findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
|
|
865
|
+
}
|
|
805
866
|
|
|
806
867
|
return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
|
|
807
868
|
}
|
package/src/starter.ts
CHANGED
|
@@ -373,7 +373,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
373
373
|
| \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
|
|
374
374
|
|
|
375
375
|
Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
|
|
376
|
-
\`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
376
|
+
\`/api/approvals\`, \`/api/issues\` (what went wrong, open until resolved), \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
377
377
|
\`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
|
|
378
378
|
\`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
|
|
379
379
|
\`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
|
|
@@ -407,6 +407,7 @@ subflow), \`foldrun flow rm-step\` removes a step, and \`foldrun agent link
|
|
|
407
407
|
trigger: manual # manual | schedule | webhook
|
|
408
408
|
schedule: "0 9 * * 1" # cron, when trigger: schedule
|
|
409
409
|
timezone: Australia/Sydney
|
|
410
|
+
owner: ana@example.com # who answers for it; else the workspace's, else the account's
|
|
410
411
|
model: default # a default for every step here
|
|
411
412
|
effort: low
|
|
412
413
|
overlap: skip # skip | queue — what a second run does while one is live
|
|
@@ -464,10 +465,11 @@ by a model: what this role is for, what it must not do, what good looks like.
|
|
|
464
465
|
---
|
|
465
466
|
name: researcher
|
|
466
467
|
description: Finds and summarises sources.
|
|
468
|
+
owner: ana@example.com # who answers for it; else the workspace's, else the account's
|
|
467
469
|
model: default # fast | default | max
|
|
468
470
|
effort: low # how hard to think about it
|
|
469
471
|
size: large # small | large | heavy — the sandbox it rents
|
|
470
|
-
tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
|
|
472
|
+
tools: [web, read, ask, peers, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step, \`peers\` lets it message the steps running beside it
|
|
471
473
|
disallowedTools: [Bash] # subtract from what it would otherwise have
|
|
472
474
|
skills: [house-style] # from skills/
|
|
473
475
|
hooks: [no-force-push] # from hooks/ or the library: code at every tool call
|
package/src/step-exec.ts
CHANGED
|
@@ -14,7 +14,7 @@ import os from "node:os";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
16
16
|
import type { HookInput, McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
17
|
-
import type
|
|
17
|
+
import { CAPTURE_CAP, type Effort, type ModelCall } from "./store.ts";
|
|
18
18
|
import { costOf, type TokenPrice } from "./catalog.ts";
|
|
19
19
|
import type { TestEffect } from "./test-mode.ts";
|
|
20
20
|
import type { OperatorEvent } from "./operator.ts";
|
|
@@ -23,7 +23,7 @@ import { checkPaths, checkBash, isFilesystemTool, linkWorkspace, workspaceLinkEn
|
|
|
23
23
|
import { DELEGATE_TOOLS, subagentGuard, toAgentDefinitions, type SubagentSpec } from "./subagents.ts";
|
|
24
24
|
import { hostSafeEnv } from "./host-env.ts";
|
|
25
25
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
26
|
-
import { CallCounter,
|
|
26
|
+
import { CallCounter, callKeysFor, type Limits } from "./limits.ts";
|
|
27
27
|
import { classifyCall, podAt, readPodEvents, type BrowserPodOutcome, type CallKind, type ClassifyContext } from "./browser-pod.ts";
|
|
28
28
|
|
|
29
29
|
export interface ExecUsage {
|
|
@@ -86,6 +86,11 @@ export interface ExecOutcome {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export interface ExecOptions {
|
|
89
|
+
/** What the step reports beyond its events (FOL-54 step 4): `metadata`
|
|
90
|
+
* sends each model call (`turn`); `full` also sends each tool call's input
|
|
91
|
+
* and output (`io`). Unset sends neither — the consult, the probe and any
|
|
92
|
+
* caller that does not store them. */
|
|
93
|
+
capture?: "metadata" | "full";
|
|
89
94
|
/** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
|
|
90
95
|
* inside its sandbox. `hookBase` is what every hook event carries. */
|
|
91
96
|
hooks?: HookDef[];
|
|
@@ -195,7 +200,50 @@ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown>
|
|
|
195
200
|
export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
|
|
196
201
|
|
|
197
202
|
/** The pairing fields on a tool event — see RunEvent in store.ts. */
|
|
198
|
-
export type EventExtra = {
|
|
203
|
+
export type EventExtra = {
|
|
204
|
+
call?: string;
|
|
205
|
+
ms?: number;
|
|
206
|
+
err?: boolean;
|
|
207
|
+
bytes?: number;
|
|
208
|
+
effect?: TestEffect;
|
|
209
|
+
operator?: OperatorEvent;
|
|
210
|
+
subagent?: string;
|
|
211
|
+
check?: boolean;
|
|
212
|
+
hook?: HookRecord;
|
|
213
|
+
/** A tool call's content, on its way to the runner (FOL-54 step 4): the
|
|
214
|
+
* runner masks it, stores it beside the run and keeps only `captured` on
|
|
215
|
+
* the event. Sent only under `capture: "full"`. */
|
|
216
|
+
io?: CallIo;
|
|
217
|
+
/** One model call, on its way to the runner's `step.calls` — not an event. */
|
|
218
|
+
turn?: ModelCall;
|
|
219
|
+
/** The model's first response of the step has begun streaming — the
|
|
220
|
+
* moment the prompt cache entry its request wrote becomes readable. Sent
|
|
221
|
+
* once; the runner's fan-out gate reads it. Not an event. */
|
|
222
|
+
responding?: true;
|
|
223
|
+
/** Set by the runner once it stored `io` (RunEvent.captured). */
|
|
224
|
+
captured?: boolean;
|
|
225
|
+
/** On a tool call: the model call (its message id) that asked for it. */
|
|
226
|
+
inCall?: string;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/** A tool call's input (on the call) or output (on its completion), capped. */
|
|
230
|
+
export interface CallIo {
|
|
231
|
+
input?: string;
|
|
232
|
+
output?: string;
|
|
233
|
+
/** The whole input's / output's length, in characters, before any cut. */
|
|
234
|
+
inputChars?: number;
|
|
235
|
+
outputChars?: number;
|
|
236
|
+
truncated?: boolean;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The cap plus slack: the runner masks, then cuts to the cap (capture.ts), so
|
|
240
|
+
// a secret on the edge is masked whole.
|
|
241
|
+
const SEND_CAP = CAPTURE_CAP + 4096;
|
|
242
|
+
|
|
243
|
+
function capped(text: string): { text: string; chars: number; truncated: boolean } {
|
|
244
|
+
if (text.length <= SEND_CAP) return { text, chars: text.length, truncated: false };
|
|
245
|
+
return { text: text.slice(0, SEND_CAP), chars: text.length, truncated: true };
|
|
246
|
+
}
|
|
199
247
|
|
|
200
248
|
/** The tag on every event a failed check writes — `verify:`, `output: json`,
|
|
201
249
|
* a schema — headline and detail alike. The runner's no-retry rule for
|
|
@@ -391,6 +439,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
391
439
|
...(opts.effort ? { effort: opts.effort } : {}),
|
|
392
440
|
systemPrompt: opts.systemPrompt,
|
|
393
441
|
...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
|
|
442
|
+
// The raw stream, only when model calls are recorded: an assistant
|
|
443
|
+
// message carries usage from the call's start (output a few tokens) and
|
|
444
|
+
// no stop reason; the call's message_delta has both (FOL-72).
|
|
445
|
+
...(opts.capture ? { includePartialMessages: true } : {}),
|
|
394
446
|
// Sub-agents: the SDK's own delegation. Every definition carries an
|
|
395
447
|
// explicit tool list (an absent one inherits all of ours).
|
|
396
448
|
...(subagents ? { agents: toAgentDefinitions(subagents) } : {}),
|
|
@@ -474,7 +526,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
474
526
|
// refused before it runs: the tool, and any paid API behind it,
|
|
475
527
|
// never sees it.
|
|
476
528
|
if (counter.active) {
|
|
477
|
-
const refusal = counter.take(
|
|
529
|
+
const refusal = counter.take(callKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners));
|
|
478
530
|
if (refusal) {
|
|
479
531
|
emit("info", `${refusal.message} (${hookInput.tool_name} refused)`, via ? { subagent: via } : undefined);
|
|
480
532
|
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: refusal.message } };
|
|
@@ -489,7 +541,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
489
541
|
// model differently from its delegates can tell them apart.
|
|
490
542
|
const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
|
|
491
543
|
for (const r of v.records) record(r, via);
|
|
492
|
-
const refundCall = () => { if (counter.active) counter.refund(
|
|
544
|
+
const refundCall = () => { if (counter.active) counter.refund(callKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners)); };
|
|
493
545
|
if (v.decision === "deny") {
|
|
494
546
|
refundCall();
|
|
495
547
|
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: v.reason! } };
|
|
@@ -558,7 +610,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
558
610
|
// anything outside it is a denial with a reason the model can act on.
|
|
559
611
|
const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
|
|
560
612
|
// A call the hook counted and this refuses never ran: give it back.
|
|
561
|
-
const refund = () => { if (counter.active) counter.refund(
|
|
613
|
+
const refund = () => { if (counter.active) counter.refund(callKeysFor(toolName, input, opts.toolOwners)); };
|
|
562
614
|
if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
|
|
563
615
|
refund();
|
|
564
616
|
return {
|
|
@@ -630,6 +682,29 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
630
682
|
// ceiling and on the bill.
|
|
631
683
|
const turns = new Map<string, { u: Record<string, number | undefined>; price: TokenPrice | null }>();
|
|
632
684
|
let anonymousTurns = 0;
|
|
685
|
+
// Each model call as a span (FOL-54 step 4), keyed like `turns`: a turn's
|
|
686
|
+
// blocks arrive as several messages under one id, so it is updated in place
|
|
687
|
+
// and sent again; the runner keeps the latest by id.
|
|
688
|
+
const spans = new Map<string, ModelCall>();
|
|
689
|
+
// From the raw stream: which call each agent is in (message_start), and each
|
|
690
|
+
// call's final output tokens and stop reason (message_delta).
|
|
691
|
+
const streaming = new Map<string, string>();
|
|
692
|
+
let saidResponding = false;
|
|
693
|
+
const finals = new Map<string, { output?: number; stop?: string }>();
|
|
694
|
+
const withFinal = (span: ModelCall): ModelCall => {
|
|
695
|
+
const f = finals.get(span.id);
|
|
696
|
+
return {
|
|
697
|
+
...span,
|
|
698
|
+
...(f?.output !== undefined ? { tokens: { ...span.tokens, output: f.output } } : {}),
|
|
699
|
+
// Without the stream (an older CLI), a call that used tools stopped to use them.
|
|
700
|
+
...(f?.stop ? { stop: f.stop } : !span.stop && span.tools > 0 ? { stop: "tool_use" } : {}),
|
|
701
|
+
};
|
|
702
|
+
};
|
|
703
|
+
// When each agent's next request went out — the step's own, and each
|
|
704
|
+
// sub-agent's apart, so one's tool results do not restart another's clock.
|
|
705
|
+
const lastInputAt = new Map<string, number>();
|
|
706
|
+
const sentAt = (via: string | null | undefined) => lastInputAt.get(via ?? "") ?? loopStartedAt;
|
|
707
|
+
let loopStartedAt = Date.now();
|
|
633
708
|
const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
|
|
634
709
|
const priceOf = (message: unknown): TokenPrice | null => {
|
|
635
710
|
const parent = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id;
|
|
@@ -705,11 +780,62 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
705
780
|
|
|
706
781
|
try {
|
|
707
782
|
for await (const message of q as AsyncIterable<SDKMessage>) {
|
|
783
|
+
// The session is up: the first model call is timed from here, not from
|
|
784
|
+
// before the SDK and its MCP servers started.
|
|
785
|
+
if (message.type === "system" && (message as { subtype?: string }).subtype === "init") loopStartedAt = Date.now();
|
|
786
|
+
if (message.type === "stream_event") {
|
|
787
|
+
const ev = (message as { event?: { type?: string; message?: { id?: string }; usage?: { output_tokens?: number }; delta?: { stop_reason?: string | null } } }).event;
|
|
788
|
+
const agentKey = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? "";
|
|
789
|
+
if (ev?.type === "message_start" && ev.message?.id) {
|
|
790
|
+
streaming.set(agentKey, ev.message.id);
|
|
791
|
+
// The step's own first response has begun: its prompt is in the
|
|
792
|
+
// cache now, so siblings held back to read it may start.
|
|
793
|
+
if (!agentKey && !saidResponding) {
|
|
794
|
+
saidResponding = true;
|
|
795
|
+
emit("info", "", { responding: true });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (ev?.type === "message_delta") {
|
|
799
|
+
const id = streaming.get(agentKey);
|
|
800
|
+
if (id) {
|
|
801
|
+
finals.set(id, {
|
|
802
|
+
...(typeof ev.usage?.output_tokens === "number" ? { output: ev.usage.output_tokens } : {}),
|
|
803
|
+
...(ev.delta?.stop_reason ? { stop: ev.delta.stop_reason } : {}),
|
|
804
|
+
});
|
|
805
|
+
const span = spans.get(id);
|
|
806
|
+
if (span) {
|
|
807
|
+
const final = withFinal(span);
|
|
808
|
+
spans.set(id, final);
|
|
809
|
+
emit("info", "", { turn: final });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
708
815
|
if (ended) break;
|
|
709
816
|
if (message.type === "assistant") {
|
|
710
|
-
const m = message.message as unknown as { id?: string; usage?: Record<string, number | undefined
|
|
817
|
+
const m = message.message as unknown as { id?: string; model?: string; stop_reason?: string | null; usage?: Record<string, number | undefined>; content?: { type?: string }[] };
|
|
711
818
|
const u = m.usage;
|
|
712
|
-
|
|
819
|
+
const turnKey = m.id ?? `turn-${anonymousTurns++}`;
|
|
820
|
+
if (u) turns.set(turnKey, { u, price: priceOf(message) });
|
|
821
|
+
if (opts.capture && u) {
|
|
822
|
+
const t = usageFromSdk(u);
|
|
823
|
+
const prior = spans.get(turnKey);
|
|
824
|
+
const span: ModelCall = {
|
|
825
|
+
id: turnKey,
|
|
826
|
+
n: prior?.n ?? spans.size + 1,
|
|
827
|
+
at: prior?.at ?? new Date().toISOString(),
|
|
828
|
+
ms: prior?.ms ?? Date.now() - sentAt(subagentOf(message)),
|
|
829
|
+
...(m.model ? { model: m.model } : prior?.model ? { model: prior.model } : {}),
|
|
830
|
+
...(m.stop_reason ? { stop: m.stop_reason } : prior?.stop ? { stop: prior.stop } : {}),
|
|
831
|
+
tokens: { input: t.inputTokens, output: t.outputTokens, cacheRead: t.cacheReadTokens, cacheWrite: t.cacheWriteTokens },
|
|
832
|
+
tools: (prior?.tools ?? 0) + (m.content ?? []).filter((b) => b?.type === "tool_use").length,
|
|
833
|
+
...(subagentOf(message) ? { subagent: subagentOf(message)! } : {}),
|
|
834
|
+
};
|
|
835
|
+
const final = withFinal(span);
|
|
836
|
+
spans.set(turnKey, final);
|
|
837
|
+
emit("info", "", { turn: final });
|
|
838
|
+
}
|
|
713
839
|
if (ceiling && u) {
|
|
714
840
|
spentUsd = turnsTotal();
|
|
715
841
|
if (spentUsd >= ceiling) {
|
|
@@ -731,13 +857,21 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
731
857
|
const input = block.input as { subagent_type?: unknown; description?: unknown; prompt?: unknown } | undefined;
|
|
732
858
|
const to = String(input?.subagent_type ?? "subagent");
|
|
733
859
|
delegations.set(block.id, to);
|
|
860
|
+
// That sub-agent's first model call is timed from its job going out.
|
|
861
|
+
lastInputAt.set(to, Date.now());
|
|
734
862
|
// One line a person can read: who got the job, and what it was.
|
|
735
863
|
const job = String(input?.description ?? input?.prompt ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
736
864
|
emit("info", `delegating to ${to}${job ? `: ${job}` : ""}`, { subagent: to });
|
|
737
865
|
}
|
|
738
866
|
openCalls.set(block.id, { name: block.name, at: Date.now(), ...(via ? { subagent: via } : {}) });
|
|
739
867
|
if (pod) ledger.set(block.id, classifyCall(block.name, block.input as Record<string, unknown> | undefined, pod));
|
|
740
|
-
|
|
868
|
+
const input = opts.capture === "full" ? capped(JSON.stringify(block.input ?? {})) : null;
|
|
869
|
+
emit("tool", block.name, {
|
|
870
|
+
call: block.id,
|
|
871
|
+
...(opts.capture ? { inCall: turnKey } : {}),
|
|
872
|
+
...(via ? { subagent: via } : {}),
|
|
873
|
+
...(input ? { io: { input: input.text, inputChars: input.chars, ...(input.truncated ? { truncated: true } : {}) } } : {}),
|
|
874
|
+
});
|
|
741
875
|
}
|
|
742
876
|
}
|
|
743
877
|
} else if (message.type === "user") {
|
|
@@ -750,6 +884,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
750
884
|
const open = openCalls.get(block.tool_use_id);
|
|
751
885
|
if (!open) continue;
|
|
752
886
|
openCalls.delete(block.tool_use_id);
|
|
887
|
+
const output = opts.capture === "full" ? capped(toolResultText(block.content)) : null;
|
|
753
888
|
emit("tool", open.name, {
|
|
754
889
|
call: block.tool_use_id,
|
|
755
890
|
ms: Date.now() - open.at,
|
|
@@ -758,6 +893,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
758
893
|
bytes: toolResultText(block.content).length,
|
|
759
894
|
...(block.is_error ? { err: true } : {}),
|
|
760
895
|
...(open.subagent ? { subagent: open.subagent } : {}),
|
|
896
|
+
...(output ? { io: { output: output.text, outputChars: output.chars, ...(output.truncated ? { truncated: true } : {}) } } : {}),
|
|
761
897
|
});
|
|
762
898
|
// Why it failed, on the trail. The span above carried only `err`,
|
|
763
899
|
// so a refused call left the agent's own guess as the only account
|
|
@@ -769,6 +905,8 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
769
905
|
}
|
|
770
906
|
readPod(block.tool_use_id);
|
|
771
907
|
}
|
|
908
|
+
// That agent's next model call is timed from here: the results went back.
|
|
909
|
+
lastInputAt.set(subagentOf(message) ?? "", Date.now());
|
|
772
910
|
}
|
|
773
911
|
} else if (message.type === "result") {
|
|
774
912
|
status = message.subtype === "success" ? "completed" : "failed";
|