@foldrun/core 2.3.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 +26 -18
- 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/model-env.d.ts +14 -2
- package/dist/src/model-env.js +34 -9
- 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 +26 -18
- 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/model-env.ts +42 -10
- 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,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
|
+
}
|
package/dist/src/translator.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/src/translator.js
CHANGED
|
@@ -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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** What became of one trigger firing. `started` is here too: without it the
|
|
2
2
|
* log answers "why did nothing run" and not "how often does anything",
|
|
3
3
|
* and the ratio is the whole point of the page. */
|
|
4
|
-
export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "overlap-skipped" | "missed" | "failed";
|
|
4
|
+
export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "halted" | "overlap-skipped" | "missed" | "failed";
|
|
5
5
|
export interface TriggerEvent {
|
|
6
6
|
t: string;
|
|
7
7
|
flow: string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DeployFile } from "./store.ts";
|
|
2
|
+
import type { ScanFinding, ScanOptions } from "./scan.ts";
|
|
3
|
+
/** What the checks read of a workspace, at most. Data (state/ and memory/,
|
|
4
|
+
* what agents write) past these is left out and named; the rest past its
|
|
5
|
+
* budget is not read at all (TreeTooLarge). On 20 Oct 2026 the daily
|
|
6
|
+
* rescan read 1.2 GB of one desk's state/ into a 2 GB heap and crash-looped
|
|
7
|
+
* the worker. The rest is 64 MiB because a deploy is 10 MiB a push. */
|
|
8
|
+
export declare const CHECK_LIMITS: {
|
|
9
|
+
readonly maxDataFileBytes: number;
|
|
10
|
+
readonly maxDataBytes: number;
|
|
11
|
+
readonly maxDataFiles: 5000;
|
|
12
|
+
readonly maxSourceBytes: number;
|
|
13
|
+
};
|
|
14
|
+
/** A workspace as the checks read it: the deploy's tree within CHECK_LIMITS.
|
|
15
|
+
* Read once and hand it to everything that checks. */
|
|
16
|
+
export declare function checkTree(dir: string, onSkip?: (path: string, bytes: number) => void): DeployFile[];
|
|
17
|
+
export declare function checkOptions(tenant: string, workspace: string): ScanOptions;
|
|
18
|
+
export declare function checkWorkspace(tenant: string, workspace: string, tree?: DeployFile[]): ScanFinding[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// One workspace's findings, as the Checks page, `GET …/check` and the daily
|
|
2
|
+
// rescan see them (FOL-58): the deploy's checks and the whole scanner, with
|
|
3
|
+
// what the files alone do not carry — the account's network: and the
|
|
4
|
+
// outward tools of the library and the workspace, nearest wins.
|
|
5
|
+
import { accountDir, workspaceDir, workspaceTools } from "./store.js";
|
|
6
|
+
import { readAgentsMdKeys } from "./agents-md.js";
|
|
7
|
+
import { libraryTools } from "./library.js";
|
|
8
|
+
import { checkFiles, readTree } from "./deploy.js";
|
|
9
|
+
/** What the checks read of a workspace, at most. Data (state/ and memory/,
|
|
10
|
+
* what agents write) past these is left out and named; the rest past its
|
|
11
|
+
* budget is not read at all (TreeTooLarge). On 20 Oct 2026 the daily
|
|
12
|
+
* rescan read 1.2 GB of one desk's state/ into a 2 GB heap and crash-looped
|
|
13
|
+
* the worker. The rest is 64 MiB because a deploy is 10 MiB a push. */
|
|
14
|
+
export const CHECK_LIMITS = {
|
|
15
|
+
maxDataFileBytes: 5 * 1024 * 1024,
|
|
16
|
+
maxDataBytes: 32 * 1024 * 1024,
|
|
17
|
+
maxDataFiles: 5000,
|
|
18
|
+
maxSourceBytes: 64 * 1024 * 1024,
|
|
19
|
+
};
|
|
20
|
+
/** A workspace as the checks read it: the deploy's tree within CHECK_LIMITS.
|
|
21
|
+
* Read once and hand it to everything that checks. */
|
|
22
|
+
export function checkTree(dir, onSkip) {
|
|
23
|
+
return readTree(dir, { ...CHECK_LIMITS, onSkip });
|
|
24
|
+
}
|
|
25
|
+
export function checkOptions(tenant, workspace) {
|
|
26
|
+
const tools = { ...libraryTools(tenant), ...workspaceTools(tenant, workspace) };
|
|
27
|
+
const outwardTools = Object.entries(tools).filter(([, d]) => d.outward === true).map(([n]) => n);
|
|
28
|
+
// AGENTS.md, or the older project.md, as the runner reads it (Codex review).
|
|
29
|
+
const accountNetwork = readAgentsMdKeys(accountDir(tenant)).network;
|
|
30
|
+
return { outwardTools, accountNetwork };
|
|
31
|
+
}
|
|
32
|
+
export function checkWorkspace(tenant, workspace, tree = checkTree(workspaceDir(tenant, workspace))) {
|
|
33
|
+
return checkFiles(tree, checkOptions(tenant, workspace));
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldrun/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Agents are just folders. The format, the runner, the checks.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=22"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
56
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.296",
|
|
57
57
|
"gray-matter": "^4.0.3",
|
|
58
58
|
"zod": "4.4.3"
|
|
59
59
|
},
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// When each agent last did anything — the question an account with hundreds
|
|
2
|
+
// of agents has to be able to answer before it can retire the ones nobody
|
|
3
|
+
// uses. Read from the run index (listRunSummaries), so it covers what the
|
|
4
|
+
// install keeps: runs older than its retention are gone, and an agent whose
|
|
5
|
+
// last run was pruned reads as never run.
|
|
6
|
+
|
|
7
|
+
import type { RunSummary } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
/** A step that started counts; one that was skipped or never reached does not. */
|
|
10
|
+
const RAN = new Set(["running", "completed", "failed", "awaiting-approval"]);
|
|
11
|
+
|
|
12
|
+
/** Agent name → the ISO time of its latest step that ran. */
|
|
13
|
+
export function agentLastRuns(runs: RunSummary[]): Map<string, string> {
|
|
14
|
+
const last = new Map<string, string>();
|
|
15
|
+
for (const run of runs) {
|
|
16
|
+
for (const step of run.steps ?? []) {
|
|
17
|
+
if (!step.agent || !RAN.has(step.status)) continue;
|
|
18
|
+
const at = step.startedAt ?? run.startedAt;
|
|
19
|
+
if (!at) continue;
|
|
20
|
+
const prev = last.get(step.agent);
|
|
21
|
+
if (!prev || at > prev) last.set(step.agent, at);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return last;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whole days since `iso`, or null for never. */
|
|
28
|
+
export function daysSince(iso: string | null | undefined, now = Date.now()): number | null {
|
|
29
|
+
if (!iso) return null;
|
|
30
|
+
const t = Date.parse(iso);
|
|
31
|
+
return Number.isFinite(t) ? Math.floor((now - t) / 86_400_000) : null;
|
|
32
|
+
}
|
package/src/agent-import.ts
CHANGED
package/src/agent-tools.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import matter from "
|
|
20
|
+
import matter from "./matter.ts";
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
23
23
|
import { resolveModel, resolveEffort, type Effort } from "./store.ts";
|
package/src/agents-md.ts
CHANGED
package/src/alerts.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `alerts:` — the thresholds a workspace wants to hear about (FOL-72 step 7):
|
|
2
|
+
//
|
|
3
|
+
// alerts:
|
|
4
|
+
// failure_rate: 30% # of its runs that finished in the last 24 hours
|
|
5
|
+
// min_runs: 5 # …once at least this many have (default 5)
|
|
6
|
+
// credits_day: 2000 # credits charged in the last 24 hours
|
|
7
|
+
// run_minutes: 45 # a run still going after this long
|
|
8
|
+
// stale_days: 30 # an agent that has not run in this many days
|
|
9
|
+
// cost_spike: 3 # a run that cost this many times its flow's 7-day average
|
|
10
|
+
// runs_per_hour: 20 # one flow started this many times in an hour
|
|
11
|
+
// new_host: true # an agent reached a site it never reached before
|
|
12
|
+
//
|
|
13
|
+
// In AGENTS.md (the workspace's, else the account's). The watchdog evaluates
|
|
14
|
+
// them on its tick and tells `notify:` once per rule a day (a slow run once
|
|
15
|
+
// per run), as event `alert`. Pure: `foldrun check` and the watchdog read the
|
|
16
|
+
// same parse.
|
|
17
|
+
|
|
18
|
+
import type { Outcome } from "./outcome.ts";
|
|
19
|
+
|
|
20
|
+
export interface AlertConfig {
|
|
21
|
+
failureRate?: number;
|
|
22
|
+
minRuns: number;
|
|
23
|
+
creditsDay?: number;
|
|
24
|
+
runMinutes?: number;
|
|
25
|
+
staleDays?: number;
|
|
26
|
+
costSpike?: number;
|
|
27
|
+
runsPerHour?: number;
|
|
28
|
+
/** Told after a run (platform host-watch.ts), not on the watchdog's tick. */
|
|
29
|
+
newHost?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const KEYS = ["failure_rate", "min_runs", "credits_day", "run_minutes", "stale_days", "cost_spike", "runs_per_hour", "new_host"];
|
|
33
|
+
|
|
34
|
+
const num = (v: unknown): number | null => {
|
|
35
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
36
|
+
if (typeof v === "string" && v.trim() && Number.isFinite(Number(v.trim()))) return Number(v.trim());
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The config, and what is wrong with it in words (for `foldrun check`). */
|
|
41
|
+
export function parseAlerts(raw: unknown): { config: AlertConfig | null; problems: string[] } {
|
|
42
|
+
if (raw === undefined || raw === null) return { config: null, problems: [] };
|
|
43
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
44
|
+
return { config: null, problems: ["alerts: is a block — failure_rate, min_runs, credits_day, run_minutes, stale_days, cost_spike, runs_per_hour, new_host"] };
|
|
45
|
+
}
|
|
46
|
+
const o = raw as Record<string, unknown>;
|
|
47
|
+
const problems: string[] = [];
|
|
48
|
+
for (const k of Object.keys(o)) if (!KEYS.includes(k)) problems.push(`alerts: ${k} — not a threshold; the keys are ${KEYS.join(", ")}`);
|
|
49
|
+
const cfg: AlertConfig = { minRuns: 5 };
|
|
50
|
+
if (o.failure_rate !== undefined) {
|
|
51
|
+
const s = String(o.failure_rate).trim();
|
|
52
|
+
const pct = /^(\d+(?:\.\d+)?)\s*%$/.exec(s);
|
|
53
|
+
const v = pct ? Number(pct[1]) / 100 : num(o.failure_rate);
|
|
54
|
+
if (v === null || v <= 0 || v > 1) problems.push(`alerts: failure_rate ${s} — a share of runs, like 30% or 0.3`);
|
|
55
|
+
else cfg.failureRate = v;
|
|
56
|
+
}
|
|
57
|
+
for (const [key, field, min] of [["min_runs", "minRuns", 1], ["credits_day", "creditsDay", 1], ["run_minutes", "runMinutes", 1], ["stale_days", "staleDays", 1], ["cost_spike", "costSpike", 1.5], ["runs_per_hour", "runsPerHour", 2]] as const) {
|
|
58
|
+
if (o[key] === undefined) continue;
|
|
59
|
+
const v = num(o[key]);
|
|
60
|
+
if (v === null || v < min) problems.push(`alerts: ${key} ${String(o[key])} — a number, at least ${min}`);
|
|
61
|
+
else (cfg as unknown as Record<string, number>)[field] = v;
|
|
62
|
+
}
|
|
63
|
+
if (o.new_host !== undefined) {
|
|
64
|
+
if (typeof o.new_host !== "boolean") problems.push(`alerts: new_host ${String(o.new_host)} — true or false`);
|
|
65
|
+
else if (o.new_host) cfg.newHost = true;
|
|
66
|
+
}
|
|
67
|
+
const any = cfg.newHost === true || cfg.failureRate !== undefined || cfg.creditsDay !== undefined || cfg.runMinutes !== undefined || cfg.staleDays !== undefined || cfg.costSpike !== undefined || cfg.runsPerHour !== undefined;
|
|
68
|
+
return { config: any ? cfg : null, problems };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AlertFacts {
|
|
72
|
+
/** Outcomes of the runs started in the last 24 hours. */
|
|
73
|
+
outcomes: Outcome[];
|
|
74
|
+
/** Credits are cents: USD charged in the last 24 hours. */
|
|
75
|
+
chargedUsd: number;
|
|
76
|
+
/** Runs still going. */
|
|
77
|
+
live: { id: string; flow: string; startedAt: string }[];
|
|
78
|
+
/** For stale_days: each agent, when it last ran (null: not in the runs
|
|
79
|
+
* kept), and since when it has existed — an agent added yesterday is not
|
|
80
|
+
* stale for never having run. */
|
|
81
|
+
agents?: { name: string; lastRunAt: string | null; since: string }[];
|
|
82
|
+
/** For cost_spike and runs_per_hour: the runs of the last seven days,
|
|
83
|
+
* test runs left out. costUsd is what its steps cost. */
|
|
84
|
+
runs?: { id: string; flow: string; startedAt: string; finishedAt: string | null; costUsd: number }[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** How few earlier runs a flow needs before its average means anything. */
|
|
88
|
+
const SPIKE_MIN_RUNS = 3;
|
|
89
|
+
|
|
90
|
+
export interface Alert {
|
|
91
|
+
/** What the once-a-day memory is keyed by: the rule, or the rule and run. */
|
|
92
|
+
key: string;
|
|
93
|
+
headline: string;
|
|
94
|
+
detail: string;
|
|
95
|
+
runId?: string;
|
|
96
|
+
flow?: string;
|
|
97
|
+
/** Said once for this key, however long it stays true (a slow run, an
|
|
98
|
+
* idle spell); the watchdog's notice memory is pruned after a week, so
|
|
99
|
+
* a condition that lasts is said again weekly at most. */
|
|
100
|
+
once?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What crosses its threshold now. */
|
|
104
|
+
export function evaluateAlerts(cfg: AlertConfig, facts: AlertFacts, workspace: string, now: number): Alert[] {
|
|
105
|
+
const out: Alert[] = [];
|
|
106
|
+
if (cfg.failureRate !== undefined) {
|
|
107
|
+
const finished = facts.outcomes.filter((o) => o === "failed" || o === "good" || o === "done" || o === "quiet" || o === "blocked" || o === "bad");
|
|
108
|
+
const failed = finished.filter((o) => o === "failed").length;
|
|
109
|
+
if (finished.length >= cfg.minRuns && failed / finished.length >= cfg.failureRate) {
|
|
110
|
+
out.push({
|
|
111
|
+
key: "failure_rate",
|
|
112
|
+
headline: `⚠ ${workspace}: ${failed} of its last ${finished.length} runs failed`,
|
|
113
|
+
detail: `${Math.round((100 * failed) / finished.length)}% in the last 24 hours, over the ${Math.round(cfg.failureRate * 100)}% in alerts: failure_rate. Its Issues show what broke.`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (cfg.creditsDay !== undefined) {
|
|
118
|
+
const credits = Math.round(facts.chargedUsd * 100);
|
|
119
|
+
if (credits >= cfg.creditsDay) {
|
|
120
|
+
out.push({
|
|
121
|
+
key: "credits_day",
|
|
122
|
+
headline: `⚠ ${workspace} was charged ${credits.toLocaleString("en")} credits in a day`,
|
|
123
|
+
detail: `Over the ${cfg.creditsDay.toLocaleString("en")} in alerts: credits_day, in the last 24 hours. Observe shows which flows and agents.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (cfg.runMinutes !== undefined) {
|
|
128
|
+
for (const r of facts.live) {
|
|
129
|
+
const mins = (now - Date.parse(r.startedAt)) / 60_000;
|
|
130
|
+
if (mins >= cfg.runMinutes) {
|
|
131
|
+
out.push({
|
|
132
|
+
key: `run_minutes/${r.id}`,
|
|
133
|
+
runId: r.id,
|
|
134
|
+
flow: r.flow,
|
|
135
|
+
headline: `⚠ ${workspace}: ${r.flow} has been running ${Math.round(mins)} minutes`,
|
|
136
|
+
detail: `Run ${r.id} is past the ${cfg.runMinutes} minutes in alerts: run_minutes — still going, maybe stuck. Stop it from its page if it is.`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (cfg.costSpike !== undefined && facts.runs) {
|
|
142
|
+
// A run that finished in the last day, against the runs of its flow that
|
|
143
|
+
// finished before it in the week: once per run.
|
|
144
|
+
const done = facts.runs.filter((r) => r.finishedAt).sort((a, b) => a.finishedAt!.localeCompare(b.finishedAt!));
|
|
145
|
+
for (const r of done) {
|
|
146
|
+
if (now - Date.parse(r.finishedAt!) > 86_400_000) continue;
|
|
147
|
+
const before = done.filter((o) => o.flow === r.flow && o.id !== r.id && o.finishedAt! < r.finishedAt!);
|
|
148
|
+
if (before.length < SPIKE_MIN_RUNS) continue;
|
|
149
|
+
const mean = before.reduce((n, o) => n + o.costUsd, 0) / before.length;
|
|
150
|
+
if (mean <= 0 || r.costUsd < cfg.costSpike * mean) continue;
|
|
151
|
+
out.push({
|
|
152
|
+
key: `cost_spike/${r.id}`,
|
|
153
|
+
runId: r.id,
|
|
154
|
+
flow: r.flow,
|
|
155
|
+
headline: `${workspace}: a ${r.flow} run cost ${(r.costUsd / mean).toFixed(1)}× its usual`,
|
|
156
|
+
detail: `Run ${r.id} cost $${r.costUsd.toFixed(2)} against an average of $${mean.toFixed(2)} over its last ${before.length} runs this week — past the ${cfg.costSpike}× in alerts: cost_spike. Its page shows which step spent it.`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (cfg.runsPerHour !== undefined && facts.runs) {
|
|
161
|
+
const hour = facts.runs.filter((r) => now - Date.parse(r.startedAt) <= 3_600_000);
|
|
162
|
+
const byFlow = new Map<string, number>();
|
|
163
|
+
for (const r of hour) byFlow.set(r.flow, (byFlow.get(r.flow) ?? 0) + 1);
|
|
164
|
+
for (const [flow, n] of byFlow) {
|
|
165
|
+
if (n < cfg.runsPerHour) continue;
|
|
166
|
+
out.push({
|
|
167
|
+
key: `runs_per_hour/${flow}`,
|
|
168
|
+
flow,
|
|
169
|
+
headline: `${workspace}: ${flow} started ${n} times in an hour`,
|
|
170
|
+
detail: `Past the ${cfg.runsPerHour} in alerts: runs_per_hour — a loop, a webhook sender retrying, or a schedule written wrong. Its Triggers page shows what started each; throttle: or debounce: caps it, and Settings → Pause stops it now.`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (cfg.staleDays !== undefined) {
|
|
175
|
+
for (const a of facts.agents ?? []) {
|
|
176
|
+
const from = Date.parse(a.lastRunAt ?? a.since);
|
|
177
|
+
if (!Number.isFinite(from)) continue;
|
|
178
|
+
const days = Math.floor((now - from) / 86_400_000);
|
|
179
|
+
if (days < cfg.staleDays) continue;
|
|
180
|
+
out.push({
|
|
181
|
+
// Keyed by the run it last made, so a run and a new idle spell are news again.
|
|
182
|
+
key: `stale_days/${a.name}/${a.lastRunAt ?? "never"}`,
|
|
183
|
+
once: true,
|
|
184
|
+
headline: `${workspace}: agent ${a.name} has not run in ${days} days`,
|
|
185
|
+
detail: `${a.lastRunAt ? `Its last step started ${a.lastRunAt.slice(0, 10)}` : "No run kept on record has used it"} — past the ${cfg.staleDays} days in alerts: stale_days. If nothing needs it any more, retire it: delete agents/${a.name}/ and redeploy, so it holds no tools or secrets nobody watches.`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
package/src/browser-pod.ts
CHANGED
|
@@ -159,6 +159,9 @@ export function classifyCall(tool: string, input: Record<string, unknown> | unde
|
|
|
159
159
|
const [, server, name] = mcp;
|
|
160
160
|
if (READ_SERVERS.has(server)) return { write: false };
|
|
161
161
|
if (server === "foldrun_ask") return write("a question to a person");
|
|
162
|
+
// tools: [peers] — reading the roster changes nothing; a message to a
|
|
163
|
+
// colleague is said, and a re-run would say it twice.
|
|
164
|
+
if (server === "foldrun_peers") return name === "peers_list" ? { write: false } : write("a message to a colleague");
|
|
162
165
|
if (server === "foldrun_scripts") {
|
|
163
166
|
if (name !== "web") return write(`script ${name}`);
|
|
164
167
|
const action = String(args.action ?? "").trim().toLowerCase();
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// A tool call's content, from a step's event to the file beside its run
|
|
2
|
+
// (FOL-54 step 4), masked twice: the step's own secrets by value (the
|
|
3
|
+
// runner's redact, from redactionsFor), then anything shaped like a
|
|
4
|
+
// credential or a private key — a key the agent read from a file or a page
|
|
5
|
+
// is not in the vault, and is still a key. Masked first, cut second, so a
|
|
6
|
+
// secret on the cap's edge is never kept half. Recording never fails the step.
|
|
7
|
+
|
|
8
|
+
import { redactText } from "./scan.ts";
|
|
9
|
+
import { writeCapturedCall, CAPTURE_CAP } from "./store.ts";
|
|
10
|
+
import { isFileValue, fileContent } from "./secrets.ts";
|
|
11
|
+
import type { CallIo } from "./step-exec.ts";
|
|
12
|
+
|
|
13
|
+
/** How much past the cap a step sends, so masking sees a secret whole
|
|
14
|
+
* before the cut (a value longer than this is a file secret, which is also
|
|
15
|
+
* masked line by line). */
|
|
16
|
+
export const CAPTURE_SLACK = 4096;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What the step's scrub replaces: every secret value it holds → its name,
|
|
20
|
+
* longest first. An `@file` secret (a key, a certificate) by its content, not
|
|
21
|
+
* its stored form; a multi-line value also line by line, because a file is
|
|
22
|
+
* read back a line at a time (the Read tool numbers each); and every value
|
|
23
|
+
* also as it appears inside JSON, where `"` `\` and newlines are escaped — a
|
|
24
|
+
* tool's input is JSON. Values under 8 characters are skipped: a two-letter
|
|
25
|
+
* secret would redact half the language, and anything that short is not one.
|
|
26
|
+
*/
|
|
27
|
+
export function redactionsFor(secrets: Record<string, string | undefined>): [string, string][] {
|
|
28
|
+
const out = new Map<string, string>();
|
|
29
|
+
const add = (v: string, name: string) => {
|
|
30
|
+
if (v.length >= 8 && !out.has(v)) out.set(v, name);
|
|
31
|
+
};
|
|
32
|
+
for (const [name, raw] of Object.entries(secrets)) {
|
|
33
|
+
if (typeof raw !== "string") continue;
|
|
34
|
+
for (const v of isFileValue(raw) ? [raw, fileContent(raw)] : [raw]) {
|
|
35
|
+
add(v, name);
|
|
36
|
+
add(v.trim(), name);
|
|
37
|
+
const escaped = JSON.stringify(v).slice(1, -1);
|
|
38
|
+
if (escaped !== v) add(escaped, name);
|
|
39
|
+
if (/\r?\n/.test(v.trim())) {
|
|
40
|
+
for (const line of v.split(/\r?\n/)) {
|
|
41
|
+
const l = line.trim();
|
|
42
|
+
if (l.length >= 16) add(l, name);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return [...out.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A private key's whole block — scan.ts names a key header and leaves the
|
|
51
|
+
// body, which is right for a finding and wrong for a stored output.
|
|
52
|
+
// Unterminated too: a key cut short, or printed without its END line, is
|
|
53
|
+
// masked to the end of the text.
|
|
54
|
+
const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$(?![\s\S]))/g;
|
|
55
|
+
|
|
56
|
+
/** Masked, then cut to the cap. */
|
|
57
|
+
export function maskCall(text: string, redactSecrets: (text: string) => string): { text: string; cut: boolean } {
|
|
58
|
+
const masked = redactText(redactSecrets(text)).replace(PRIVATE_KEY, "[redacted:private key]");
|
|
59
|
+
return masked.length > CAPTURE_CAP ? { text: masked.slice(0, CAPTURE_CAP), cut: true } : { text: masked, cut: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function recordCallIo(
|
|
63
|
+
workspaceRoot: string,
|
|
64
|
+
runId: string,
|
|
65
|
+
callId: string,
|
|
66
|
+
tool: string,
|
|
67
|
+
io: CallIo,
|
|
68
|
+
/** The step's own scrub: its resolved secret values → `[redacted:NAME]`. */
|
|
69
|
+
redactSecrets: (text: string) => string,
|
|
70
|
+
): boolean {
|
|
71
|
+
try {
|
|
72
|
+
const input = io.input === undefined ? null : maskCall(io.input, redactSecrets);
|
|
73
|
+
const output = io.output === undefined ? null : maskCall(io.output, redactSecrets);
|
|
74
|
+
writeCapturedCall(workspaceRoot, runId, {
|
|
75
|
+
id: callId,
|
|
76
|
+
tool,
|
|
77
|
+
// Which part was cut, each on its own: in the sandbox (the whole was
|
|
78
|
+
// longer than what was sent) or here, after masking.
|
|
79
|
+
...(input ? { input: input.text, ...(io.inputChars !== undefined ? { inputChars: io.inputChars } : {}), ...(input.cut || (io.inputChars ?? 0) > io.input!.length ? { inputCut: true } : {}) } : {}),
|
|
80
|
+
...(output ? { output: output.text, ...(io.outputChars !== undefined ? { outputChars: io.outputChars } : {}), ...(output.cut || (io.outputChars ?? 0) > io.output!.length ? { outputCut: true } : {}) } : {}),
|
|
81
|
+
...(io.truncated || input?.cut || output?.cut ? { truncated: true } : {}),
|
|
82
|
+
});
|
|
83
|
+
return true;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(`[capture] ${tool} (${callId}) for ${runId}:`, err instanceof Error ? err.message : err);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|