@foldrun/core 2.2.0 → 2.3.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/api-tools.d.ts +5 -1
- package/dist/src/api-tools.js +19 -10
- package/dist/src/run-container.d.ts +4 -0
- package/dist/src/run-container.js +9 -1
- package/dist/src/runner.js +20 -2
- package/dist/src/script-tools.d.ts +4 -1
- package/dist/src/script-tools.js +9 -3
- package/dist/src/step-exec.d.ts +4 -0
- package/dist/src/step-exec.js +7 -0
- package/dist/src/store.d.ts +17 -39
- package/dist/src/store.js +4 -52
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +58 -0
- package/dist/src/tool-output.d.ts +29 -0
- package/dist/src/tool-output.js +64 -0
- package/package.json +1 -1
- package/src/api-tools.ts +17 -7
- package/src/run-container.ts +13 -2
- package/src/runner.ts +19 -1
- package/src/script-tools.ts +8 -2
- package/src/step-exec.ts +10 -1
- package/src/store.ts +19 -69
- package/src/tokens.ts +75 -0
- package/src/tool-output.ts +82 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// A tool result too long to send whole: kept in a file, previewed inline.
|
|
2
|
+
//
|
|
3
|
+
// A tool result stays in the conversation and is sent again on every later
|
|
4
|
+
// turn of the step, so one 20,000-character answer costs its tokens over and
|
|
5
|
+
// over (FOL-28: ~470k input tokens on a 3-line answer). Script and API tools
|
|
6
|
+
// used to cut at 20,000 characters and drop the rest — the model saw a
|
|
7
|
+
// fragment and could not ask for more. Now the whole output is written under
|
|
8
|
+
// the agent's `.cache/results/`, the model gets the head and the path, and
|
|
9
|
+
// reads on with Read (offset/limit) or Grep only if it needs to.
|
|
10
|
+
//
|
|
11
|
+
// `.cache/` is inside the workspace, so the file tools reach it, and is never
|
|
12
|
+
// copied back from a run's sandbox (run-container.ts DENY_BACK_CACHE): a
|
|
13
|
+
// result is the step's working material, not workspace content.
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
/** What the model sees inline, at most: about 3,000 tokens. */
|
|
18
|
+
export const RESULT_PREVIEW_CHARS = 12_000;
|
|
19
|
+
/** What a tool's output is collected up to before it is kept — enough for a
|
|
20
|
+
* big API page or a long script log, bounded so a runaway stream cannot
|
|
21
|
+
* fill the sandbox's memory. */
|
|
22
|
+
export const RESULT_KEEP_CHARS = 2_000_000;
|
|
23
|
+
/** The results folder, relative to the agent's working directory. */
|
|
24
|
+
export const RESULTS_DIR = ".cache/results";
|
|
25
|
+
/** What an agent that cannot read files gets: the whole result up to the
|
|
26
|
+
* old cut, as before 18 Oct 2026 — a pointer to a file it has no tool to
|
|
27
|
+
* open would only lose it the tail. */
|
|
28
|
+
export const UNREADABLE_CAP = 20_000;
|
|
29
|
+
/** Does a step granted these tools hold one that reads a kept result back? */
|
|
30
|
+
export function canReadBack(allowed) {
|
|
31
|
+
return allowed.includes("Read") || allowed.includes("Grep");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `text` as the model should see it: unchanged when short; otherwise its
|
|
35
|
+
* head — cut at a line break when one falls in the last fifth — and where
|
|
36
|
+
* the whole of it is. `label` names the file (the tool's name). An agent
|
|
37
|
+
* that cannot read files back, or no folder to keep it in: the whole result
|
|
38
|
+
* up to UNREADABLE_CAP, as it always was.
|
|
39
|
+
*/
|
|
40
|
+
export function previewOrKeep(text, keep, label, cap = RESULT_PREVIEW_CHARS) {
|
|
41
|
+
const whole = () => (text.length > UNREADABLE_CAP ? `${text.slice(0, UNREADABLE_CAP)}\n…[truncated]` : text);
|
|
42
|
+
if (!keep || !keep.readable())
|
|
43
|
+
return whole();
|
|
44
|
+
const agentDir = keep.dir;
|
|
45
|
+
if (text.length <= cap)
|
|
46
|
+
return text;
|
|
47
|
+
let cut = text.lastIndexOf("\n", cap);
|
|
48
|
+
if (cut < cap * 0.8)
|
|
49
|
+
cut = cap;
|
|
50
|
+
const head = text.slice(0, cut);
|
|
51
|
+
const more = text.length - cut;
|
|
52
|
+
const name = `${label.replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 60)}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}.txt`;
|
|
53
|
+
const rel = path.posix.join(RESULTS_DIR, name);
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(path.join(agentDir, RESULTS_DIR), { recursive: true });
|
|
56
|
+
fs.writeFileSync(path.join(agentDir, rel), text);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Nowhere to keep it: what it would have had before, rather than a tail lost.
|
|
60
|
+
return whole();
|
|
61
|
+
}
|
|
62
|
+
return (`${head}\n…(${more} more characters not shown — the whole result, ${text.length} characters, is in \`${rel}\`. ` +
|
|
63
|
+
`Read it with offset/limit, or Grep it, if you need what is past this point.)`);
|
|
64
|
+
}
|
package/package.json
CHANGED
package/src/api-tools.ts
CHANGED
|
@@ -18,10 +18,13 @@ import { EGRESS_ENV, viaEgress } from "./egress.ts";
|
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { createSdkMcpServer, tool, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
|
|
20
20
|
import type { ApiSpec } from "./store.ts";
|
|
21
|
+
import { previewOrKeep, RESULT_KEEP_CHARS, type KeepIn } from "./tool-output.ts";
|
|
21
22
|
import type { OperationSpec } from "./openapi.ts";
|
|
22
23
|
import { resolveSecrets, materializeSecrets } from "./secrets.ts";
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
// Collected whole up to this; the model sees a preview and where the rest is
|
|
26
|
+
// kept (tool-output.ts), not a cut with the rest gone.
|
|
27
|
+
const MAX_BODY_CHARS = RESULT_KEEP_CHARS;
|
|
25
28
|
/** MCP tool names are capped; ours also carry the server prefix on the wire. */
|
|
26
29
|
const MAX_TOOL_NAME = 64;
|
|
27
30
|
/** How many typed tools the prompt lists by name before summarising. */
|
|
@@ -140,6 +143,8 @@ async function performRequest(
|
|
|
140
143
|
bucket: TokenBucket | null,
|
|
141
144
|
log: string[],
|
|
142
145
|
req: Request,
|
|
146
|
+
/** Where a long body is kept, when the agent can read it back (tool-output.ts). */
|
|
147
|
+
keepIn?: KeepIn,
|
|
143
148
|
): Promise<ToolReply> {
|
|
144
149
|
const started = Date.now();
|
|
145
150
|
try {
|
|
@@ -180,10 +185,11 @@ async function performRequest(
|
|
|
180
185
|
...(api.timeout ? { signal: AbortSignal.timeout(api.timeout * 1000) } : {}),
|
|
181
186
|
});
|
|
182
187
|
const text = await res.text();
|
|
183
|
-
const
|
|
188
|
+
const body =
|
|
184
189
|
text.length > MAX_BODY_CHARS
|
|
185
190
|
? `${text.slice(0, MAX_BODY_CHARS)}\n…[truncated ${text.length - MAX_BODY_CHARS} chars]`
|
|
186
191
|
: text;
|
|
192
|
+
const truncated = previewOrKeep(body, keepIn, `${api.name}-${req.method.toLowerCase()}`);
|
|
187
193
|
|
|
188
194
|
log.push(
|
|
189
195
|
`${req.method} ${url.pathname}${url.search ? "?…" : ""} → ${res.status} (${Date.now() - started}ms)${waitNote}`,
|
|
@@ -236,6 +242,7 @@ function typedTool(
|
|
|
236
242
|
env: Record<string, string>,
|
|
237
243
|
bucket: TokenBucket | null,
|
|
238
244
|
log: string[],
|
|
245
|
+
keepIn?: KeepIn,
|
|
239
246
|
) {
|
|
240
247
|
const keys = argKeys(op);
|
|
241
248
|
const shape: Record<string, z.ZodTypeAny> = {};
|
|
@@ -285,11 +292,11 @@ function typedTool(
|
|
|
285
292
|
query,
|
|
286
293
|
headers,
|
|
287
294
|
body: typeof values.body === "string" ? values.body : undefined,
|
|
288
|
-
});
|
|
295
|
+
}, keepIn);
|
|
289
296
|
});
|
|
290
297
|
}
|
|
291
298
|
|
|
292
|
-
function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBucket | null, log: string[]) {
|
|
299
|
+
function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBucket | null, log: string[], keepIn?: KeepIn) {
|
|
293
300
|
const typed = api.resolvedOperations?.length
|
|
294
301
|
? ` Prefer the typed ${api.name}_* tools; this one is the escape hatch for operations they do not cover.`
|
|
295
302
|
: "";
|
|
@@ -314,7 +321,7 @@ function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBuc
|
|
|
314
321
|
query: args.query ?? {},
|
|
315
322
|
headers: {},
|
|
316
323
|
body: args.body,
|
|
317
|
-
}),
|
|
324
|
+
}, keepIn),
|
|
318
325
|
);
|
|
319
326
|
}
|
|
320
327
|
|
|
@@ -325,6 +332,9 @@ export function buildApiTools(
|
|
|
325
332
|
// The runner container passes this: secrets were resolved before the specs
|
|
326
333
|
// crossed the boundary, and there is no vault in there to ask.
|
|
327
334
|
resolved?: { env: Record<string, string | undefined>; missing: string[] },
|
|
335
|
+
/** Where a body too long to send whole is kept and previewed, when the
|
|
336
|
+
* agent can read it back (tool-output.ts). Without it, the old 20k cut. */
|
|
337
|
+
keepIn?: KeepIn,
|
|
328
338
|
): ApiToolResult {
|
|
329
339
|
if (apis.length === 0) {
|
|
330
340
|
return { server: null, toolNames: [], owners: {}, methods: {}, missingSecrets: [], promptLines: [], drainLog: () => [] };
|
|
@@ -383,11 +393,11 @@ export function buildApiTools(
|
|
|
383
393
|
|
|
384
394
|
const typedNames: string[] = [];
|
|
385
395
|
for (const op of ops) {
|
|
386
|
-
tools.push(typedTool(api, op, env, bucket, log));
|
|
396
|
+
tools.push(typedTool(api, op, env, bucket, log, keepIn));
|
|
387
397
|
typedNames.push(typedToolName(api.name, op.id));
|
|
388
398
|
methods[`mcp__foldrun_apis__${typedToolName(api.name, op.id)}`] = String(op.method ?? "").toUpperCase();
|
|
389
399
|
}
|
|
390
|
-
if (withGeneric) tools.push(genericTool(api, env, bucket, log));
|
|
400
|
+
if (withGeneric) tools.push(genericTool(api, env, bucket, log, keepIn));
|
|
391
401
|
|
|
392
402
|
const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
|
|
393
403
|
for (const n of names) {
|
package/src/run-container.ts
CHANGED
|
@@ -186,6 +186,9 @@ export interface ContainerStepOutcome {
|
|
|
186
186
|
data?: unknown;
|
|
187
187
|
costUsd: number | null;
|
|
188
188
|
usage?: ExecUsage | null;
|
|
189
|
+
/** Model turns, and why the last one ended — see ExecOutcome. */
|
|
190
|
+
turns?: number;
|
|
191
|
+
stopReason?: string | null;
|
|
189
192
|
/** Every turn priced at its own model, when sub-agents ran on others —
|
|
190
193
|
* see ExecOutcome.turnsCostUsd. */
|
|
191
194
|
turnsCostUsd?: number;
|
|
@@ -672,13 +675,18 @@ try {
|
|
|
672
675
|
// buildApiTools gets an already-resolved environment. verify: gets the
|
|
673
676
|
// container's own environment, whole: it is the boundary, assembled on
|
|
674
677
|
// the host from an allowlist, and the declared secrets are in it.
|
|
675
|
-
|
|
678
|
+
// A long result is kept in a file only when the agent can read it back.
|
|
679
|
+
// (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)
|
|
680
|
+
const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === "Read" || t === "Grep") };
|
|
681
|
+
const api = buildApiTools("", input.apis, undefined, { env, missing: [] }, keepIn);
|
|
676
682
|
const script = buildScriptTools(
|
|
677
683
|
agentDir,
|
|
678
684
|
input.scripts,
|
|
679
685
|
{ ...env, ...runtime.env },
|
|
680
686
|
"/library/scripts",
|
|
681
687
|
runtime.interpreters,
|
|
688
|
+
null,
|
|
689
|
+
keepIn.readable,
|
|
682
690
|
);
|
|
683
691
|
// The translator, when the provider speaks Chat Completions: a loopback
|
|
684
692
|
// server for the length of this step. Its env replaces the endpoint the
|
|
@@ -1184,7 +1192,7 @@ export function buildFailureSummary(log: string, opts: { tail?: number; maxLines
|
|
|
1184
1192
|
|
|
1185
1193
|
export function parseDriverLine(
|
|
1186
1194
|
line: string,
|
|
1187
|
-
): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord } | { e: "done" } & ContainerStepOutcome | null {
|
|
1195
|
+
): { e: "event"; type: "text" | "tool" | "info" | "error"; text: string; call?: string; ms?: number; err?: boolean; bytes?: number; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord } | { e: "done" } & ContainerStepOutcome | null {
|
|
1188
1196
|
const trimmed = line.trim();
|
|
1189
1197
|
if (!trimmed.startsWith("{")) return null;
|
|
1190
1198
|
try {
|
|
@@ -1197,6 +1205,7 @@ export function parseDriverLine(
|
|
|
1197
1205
|
...(typeof parsed.call === "string" ? { call: parsed.call } : {}),
|
|
1198
1206
|
...(typeof parsed.ms === "number" ? { ms: parsed.ms } : {}),
|
|
1199
1207
|
...(parsed.err === true ? { err: true } : {}),
|
|
1208
|
+
...(typeof parsed.bytes === "number" && parsed.bytes >= 0 ? { bytes: parsed.bytes } : {}),
|
|
1200
1209
|
...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
|
|
1201
1210
|
...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
|
|
1202
1211
|
...(parsed.check === true ? { check: true } : {}),
|
|
@@ -1212,6 +1221,8 @@ export function parseDriverLine(
|
|
|
1212
1221
|
...("data" in parsed ? { data: parsed.data } : {}),
|
|
1213
1222
|
costUsd: typeof parsed.costUsd === "number" ? parsed.costUsd : null,
|
|
1214
1223
|
...(typeof parsed.turnsCostUsd === "number" ? { turnsCostUsd: parsed.turnsCostUsd } : {}),
|
|
1224
|
+
...(typeof parsed.turns === "number" && parsed.turns >= 0 ? { turns: parsed.turns } : {}),
|
|
1225
|
+
...(typeof parsed.stopReason === "string" ? { stopReason: parsed.stopReason.slice(0, 64) } : {}),
|
|
1215
1226
|
...(parseBrowserPod(parsed.browserPod) ? { browserPod: parseBrowserPod(parsed.browserPod)! } : {}),
|
|
1216
1227
|
usage:
|
|
1217
1228
|
parsed.usage &&
|
package/src/runner.ts
CHANGED
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
listAgents,
|
|
83
83
|
runCost,
|
|
84
84
|
} from "./store.ts";
|
|
85
|
+
import { canReadBack } from "./tool-output.ts";
|
|
85
86
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
|
|
86
87
|
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
|
|
87
88
|
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
@@ -1049,7 +1050,12 @@ export function agentContext(
|
|
|
1049
1050
|
const attached = attachOperations(apis, tenant, path.resolve(agentDir, "..", ".."));
|
|
1050
1051
|
const apiWarnings = attached.warnings;
|
|
1051
1052
|
apis.splice(0, apis.length, ...attached.specs);
|
|
1052
|
-
|
|
1053
|
+
// A long tool result is kept in a file only for an agent that can read it
|
|
1054
|
+
// back (tool-output.ts). The grants are settled further down, after the
|
|
1055
|
+
// tools are built, so the tools ask when they are called.
|
|
1056
|
+
let readsFiles = false;
|
|
1057
|
+
const keepIn = { dir: agentDir, readable: () => readsFiles };
|
|
1058
|
+
const apiTools = buildApiTools(tenant, apis, workspace, undefined, keepIn);
|
|
1053
1059
|
if (apiTools.promptLines.length) {
|
|
1054
1060
|
parts.push(`# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
|
|
1055
1061
|
}
|
|
@@ -1396,6 +1402,7 @@ export function agentContext(
|
|
|
1396
1402
|
libraryDir(tenant, "scripts"),
|
|
1397
1403
|
runtime.interpreters,
|
|
1398
1404
|
exec,
|
|
1405
|
+
keepIn.readable,
|
|
1399
1406
|
);
|
|
1400
1407
|
parts.push(
|
|
1401
1408
|
`# Clock\n\nToday is ${clockEnv.FOLDRUN_DATE} (${timezone}). Use this date for anything you ` +
|
|
@@ -1610,6 +1617,7 @@ export function agentContext(
|
|
|
1610
1617
|
const denied: string[] = refNames(front.disallowedTools);
|
|
1611
1618
|
if (denied.includes("install")) denied.push(INSTALL_TOOL);
|
|
1612
1619
|
const finalAllowed = allowed.filter((t) => !denied.includes(t));
|
|
1620
|
+
readsFiles = canReadBack(finalAllowed);
|
|
1613
1621
|
|
|
1614
1622
|
return {
|
|
1615
1623
|
front,
|
|
@@ -1784,6 +1792,7 @@ export function recordAttempt(
|
|
|
1784
1792
|
status,
|
|
1785
1793
|
costUsd: step.costUsd,
|
|
1786
1794
|
tokens: step.tokens ?? null,
|
|
1795
|
+
...(typeof step.turns === "number" ? { turns: step.turns } : {}),
|
|
1787
1796
|
computeSecs: step.computeSecs ?? null,
|
|
1788
1797
|
startedAt: step.podLostTry?.finishedAt ?? startedAt,
|
|
1789
1798
|
finishedAt: step.finishedAt ?? new Date().toISOString(),
|
|
@@ -1797,6 +1806,7 @@ export function recordAttempt(
|
|
|
1797
1806
|
step.costUsd = totals.costUsd;
|
|
1798
1807
|
step.tokens = totals.tokens;
|
|
1799
1808
|
step.computeSecs = totals.computeSecs;
|
|
1809
|
+
if (totals.turns !== undefined) step.turns = totals.turns;
|
|
1800
1810
|
}
|
|
1801
1811
|
|
|
1802
1812
|
/** A question an agent asked mid-step goes where approvals go (the
|
|
@@ -2030,6 +2040,9 @@ async function runStep(
|
|
|
2030
2040
|
} = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
|
|
2031
2041
|
disposeRuntime = runtime.dispose;
|
|
2032
2042
|
canActOutward = outward;
|
|
2043
|
+
// Sent again on every turn; with turns and the tools' result sizes, what
|
|
2044
|
+
// a token report reads to say where a step's input went.
|
|
2045
|
+
step.systemPromptChars = systemPrompt.length;
|
|
2033
2046
|
// A retry that moved the step up a class (an evicted or OOM-killed
|
|
2034
2047
|
// attempt) wins over the agent's own `size:` for the attempts after.
|
|
2035
2048
|
const size = step.sizeUp ?? agentSize;
|
|
@@ -2831,6 +2844,7 @@ async function runStep(
|
|
|
2831
2844
|
status: "lost",
|
|
2832
2845
|
costUsd: repriced(catalog, wireModel, first.usage ?? null, first.costUsd, push, first.turnsCostUsd),
|
|
2833
2846
|
tokens: tokensOf(first.usage),
|
|
2847
|
+
...(typeof first.turns === "number" ? { turns: first.turns } : {}),
|
|
2834
2848
|
computeSecs: first.timing ? Math.round(first.timing.totalMs) / 1000 : null,
|
|
2835
2849
|
finishedAt: new Date().toISOString(),
|
|
2836
2850
|
error: decision.note.slice(0, 500),
|
|
@@ -2875,6 +2889,8 @@ async function runStep(
|
|
|
2875
2889
|
if (outcome.data !== undefined) step.data = redactData(outcome.data);
|
|
2876
2890
|
step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push, outcome.turnsCostUsd);
|
|
2877
2891
|
step.tokens = tokensOf(outcome.usage);
|
|
2892
|
+
step.turns = typeof outcome.turns === "number" ? outcome.turns : null;
|
|
2893
|
+
step.stopReason = outcome.stopReason ?? null;
|
|
2878
2894
|
step.actual = outcome.res ?? null;
|
|
2879
2895
|
if (outcome.res?.peakMemBytes || outcome.res?.rxBytes) {
|
|
2880
2896
|
const mb = (n: number | null) => (n === null ? "?" : (n / 1024 / 1024).toFixed(0));
|
|
@@ -3077,6 +3093,8 @@ async function runStep(
|
|
|
3077
3093
|
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push, outcome.turnsCostUsd);
|
|
3078
3094
|
step.costUsd = stepCost === null && consultCost === 0 ? null : (stepCost ?? 0) + consultCost;
|
|
3079
3095
|
step.tokens = tokensOf(outcome.usage);
|
|
3096
|
+
step.turns = typeof outcome.turns === "number" ? outcome.turns : null;
|
|
3097
|
+
step.stopReason = outcome.stopReason ?? null;
|
|
3080
3098
|
for (const line of apiTools.drainLog()) push("info", `api: ${line}`);
|
|
3081
3099
|
for (const line of scriptTools.drainLog()) push("info", `script: ${line}`);
|
|
3082
3100
|
}
|
package/src/script-tools.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { runInContainer } from "./container.ts";
|
|
|
23
23
|
import { hostSafeEnv } from "./host-env.ts";
|
|
24
24
|
import { workspaceLinkEnv } from "./confine.ts";
|
|
25
25
|
import { platform } from "./platform.ts";
|
|
26
|
+
import { previewOrKeep, RESULT_KEEP_CHARS } from "./tool-output.ts";
|
|
26
27
|
|
|
27
28
|
// A script's only clock is the `timeout:` its own tool.md declares. There
|
|
28
29
|
// was a two-minute default and a ten-minute cap here; both were the
|
|
@@ -30,7 +31,9 @@ import { platform } from "./platform.ts";
|
|
|
30
31
|
// deploy watcher had to be taught to call its tool four times to outlast the
|
|
31
32
|
// cap. The platform orchestrates; it does not decide that.
|
|
32
33
|
const timeoutFor = (spec: ScriptSpec) => (spec.timeout ? spec.timeout * 1000 : undefined);
|
|
33
|
-
|
|
34
|
+
// Collected up to this, then previewed and kept (tool-output.ts): the model
|
|
35
|
+
// sees the head and where the rest is, instead of a cut with the rest gone.
|
|
36
|
+
const MAX_OUTPUT = RESULT_KEEP_CHARS;
|
|
34
37
|
|
|
35
38
|
export interface ScriptSpec {
|
|
36
39
|
name: string;
|
|
@@ -419,6 +422,9 @@ export function buildScriptTools(
|
|
|
419
422
|
libraryScripts = "",
|
|
420
423
|
interpreters: Record<string, string> = {},
|
|
421
424
|
exec: ExecutionContext | null = null,
|
|
425
|
+
/** Whether the agent can read files back (Read or Grep): only then is a
|
|
426
|
+
* long output kept in a file and previewed (tool-output.ts). */
|
|
427
|
+
readable: () => boolean = () => false,
|
|
422
428
|
): ScriptToolResult {
|
|
423
429
|
if (scripts.length === 0) {
|
|
424
430
|
return { server: null, toolNames: [], promptLines: [], drainLog: () => [] };
|
|
@@ -453,7 +459,7 @@ export function buildScriptTools(
|
|
|
453
459
|
`(${Date.now() - started}ms, ${exec?.executor ?? "sandbox"})`,
|
|
454
460
|
);
|
|
455
461
|
return {
|
|
456
|
-
content: [{ type: "text" as const, text: `exit ${code ?? "error"}\n\n${out}` }],
|
|
462
|
+
content: [{ type: "text" as const, text: `exit ${code ?? "error"}\n\n${previewOrKeep(out, { dir: agentDir, readable }, spec.name)}` }],
|
|
457
463
|
isError: code !== 0,
|
|
458
464
|
};
|
|
459
465
|
},
|
package/src/step-exec.ts
CHANGED
|
@@ -76,6 +76,9 @@ export interface ExecOutcome {
|
|
|
76
76
|
* end_turn, max_tokens, stop_sequence, tool_use, pause_turn, refusal or
|
|
77
77
|
* model_context_window_exceeded. Null when no result arrived. */
|
|
78
78
|
stopReason?: string | null;
|
|
79
|
+
/** Model turns the step took (the SDK's num_turns; counted from the
|
|
80
|
+
* turns seen when no closing result arrived). */
|
|
81
|
+
turns?: number;
|
|
79
82
|
/** Set when the step browsed through the account's browser pod from the
|
|
80
83
|
* slim image (ExecOptions.browserPod): its reconnects, and — when the pod
|
|
81
84
|
* was lost — why, and what the step had written by then. */
|
|
@@ -192,7 +195,7 @@ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown>
|
|
|
192
195
|
export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
|
|
193
196
|
|
|
194
197
|
/** The pairing fields on a tool event — see RunEvent in store.ts. */
|
|
195
|
-
export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord };
|
|
198
|
+
export type EventExtra = { call?: string; ms?: number; err?: boolean; bytes?: number; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord };
|
|
196
199
|
|
|
197
200
|
/** The tag on every event a failed check writes — `verify:`, `output: json`,
|
|
198
201
|
* a schema — headline and detail alike. The runner's no-retry rule for
|
|
@@ -312,6 +315,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
312
315
|
let costUsd: number | null = null;
|
|
313
316
|
let usage: ExecOutcome["usage"] = null;
|
|
314
317
|
let stopReason: string | null = null;
|
|
318
|
+
let numTurns: number | null = null;
|
|
315
319
|
const texts: string[] = [];
|
|
316
320
|
|
|
317
321
|
fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
|
|
@@ -749,6 +753,9 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
749
753
|
emit("tool", open.name, {
|
|
750
754
|
call: block.tool_use_id,
|
|
751
755
|
ms: Date.now() - open.at,
|
|
756
|
+
// How much the result put into the conversation — sent again on
|
|
757
|
+
// every later turn, so the size is what a token report needs.
|
|
758
|
+
bytes: toolResultText(block.content).length,
|
|
752
759
|
...(block.is_error ? { err: true } : {}),
|
|
753
760
|
...(open.subagent ? { subagent: open.subagent } : {}),
|
|
754
761
|
});
|
|
@@ -774,6 +781,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
774
781
|
}
|
|
775
782
|
costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
|
|
776
783
|
stopReason = "stop_reason" in message && typeof message.stop_reason === "string" ? message.stop_reason : null;
|
|
784
|
+
if ("num_turns" in message && typeof message.num_turns === "number") numTurns = message.num_turns;
|
|
777
785
|
if ("usage" in message && message.usage) {
|
|
778
786
|
// Cache reads and writes counted beside the input total: with only
|
|
779
787
|
// the total (until 18 Oct 2026) the hit rate could not be read and
|
|
@@ -941,6 +949,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
941
949
|
status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage,
|
|
942
950
|
...(mixedModels() ? { turnsCostUsd: turnsTotal() } : {}),
|
|
943
951
|
stopReason,
|
|
952
|
+
turns: numTurns ?? turns.size,
|
|
944
953
|
...(pod ? { browserPod: podState } : {}),
|
|
945
954
|
};
|
|
946
955
|
}
|
package/src/store.ts
CHANGED
|
@@ -3046,6 +3046,10 @@ export interface RunEvent {
|
|
|
3046
3046
|
call?: string;
|
|
3047
3047
|
ms?: number;
|
|
3048
3048
|
err?: boolean;
|
|
3049
|
+
/** On a tool completion: the result's length in characters — what it put
|
|
3050
|
+
* into the conversation, which every later turn sends again. Absent before
|
|
3051
|
+
* 18 Oct 2026. */
|
|
3052
|
+
bytes?: number;
|
|
3049
3053
|
/** Set on a test run's event that reports what the platform did instead
|
|
3050
3054
|
* of the real thing — a refused send, a redirected email, a diverted
|
|
3051
3055
|
* state write, a withheld secret. The run page lists these; the text is
|
|
@@ -3103,6 +3107,8 @@ export interface StepAttempt {
|
|
|
3103
3107
|
status: "completed" | "failed" | "lost";
|
|
3104
3108
|
costUsd: number | null;
|
|
3105
3109
|
tokens: TokenUsage | null;
|
|
3110
|
+
/** Model turns this attempt took; absent before 18 Oct 2026. */
|
|
3111
|
+
turns?: number | null;
|
|
3106
3112
|
computeSecs: number | null;
|
|
3107
3113
|
startedAt: string;
|
|
3108
3114
|
finishedAt: string;
|
|
@@ -3118,85 +3124,21 @@ export interface StepAttempt {
|
|
|
3118
3124
|
/** The step's totals across its attempts, from the rows. Null where no
|
|
3119
3125
|
* attempt reported the figure — an in-process step rents no sandbox, and
|
|
3120
3126
|
* billing reads null as zero, not as "unknown, charge something". */
|
|
3121
|
-
export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs"> {
|
|
3127
|
+
export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs" | "turns"> {
|
|
3122
3128
|
const costs = tries.map((t) => t.costUsd).filter((c): c is number => typeof c === "number");
|
|
3129
|
+
const turns = tries.map((t) => t.turns).filter((c): c is number => typeof c === "number");
|
|
3123
3130
|
const toks = tries.map((t) => t.tokens).filter((t): t is TokenUsage => !!t);
|
|
3124
3131
|
const secs = tries.map((t) => t.computeSecs).filter((c): c is number => typeof c === "number");
|
|
3125
3132
|
return {
|
|
3126
3133
|
costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
|
|
3127
3134
|
tokens: toks.length ? sumTokens(toks) : null,
|
|
3128
3135
|
computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
|
|
3136
|
+
...(turns.length ? { turns: turns.reduce((a, b) => a + b, 0) } : {}),
|
|
3129
3137
|
};
|
|
3130
3138
|
}
|
|
3131
3139
|
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
* model saw; `cacheRead` and `cacheWrite` are the part of it the prompt
|
|
3135
|
-
* cache served or stored — a read costs a tenth of fresh input, and a usage
|
|
3136
|
-
* page wants to say how much of a step's context was re-read rather than
|
|
3137
|
-
* re-sent. Steps recorded before 18 Oct 2026 have no cache fields: their
|
|
3138
|
-
* `input` is the same total, but how much of it was cached is unknown.
|
|
3139
|
-
*/
|
|
3140
|
-
export interface TokenUsage {
|
|
3141
|
-
input: number;
|
|
3142
|
-
output: number;
|
|
3143
|
-
cacheRead?: number;
|
|
3144
|
-
cacheWrite?: number;
|
|
3145
|
-
}
|
|
3146
|
-
|
|
3147
|
-
/** The record's shape from what a step reports (ExecOutcome.usage). */
|
|
3148
|
-
export function tokensOf(usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | null | undefined): TokenUsage | null {
|
|
3149
|
-
if (!usage) return null;
|
|
3150
|
-
return {
|
|
3151
|
-
input: usage.inputTokens,
|
|
3152
|
-
output: usage.outputTokens,
|
|
3153
|
-
cacheRead: usage.cacheReadTokens ?? 0,
|
|
3154
|
-
cacheWrite: usage.cacheWriteTokens ?? 0,
|
|
3155
|
-
};
|
|
3156
|
-
}
|
|
3157
|
-
|
|
3158
|
-
/** Field-by-field sum; the cache fields are present in the result whenever
|
|
3159
|
-
* any input had them, so a run of old and new steps still reads whole. */
|
|
3160
|
-
export function sumTokens(list: readonly TokenUsage[]): TokenUsage {
|
|
3161
|
-
const out: TokenUsage = { input: 0, output: 0 };
|
|
3162
|
-
for (const t of list) {
|
|
3163
|
-
out.input += t.input;
|
|
3164
|
-
out.output += t.output;
|
|
3165
|
-
if (t.cacheRead !== undefined || t.cacheWrite !== undefined) {
|
|
3166
|
-
out.cacheRead = (out.cacheRead ?? 0) + (t.cacheRead ?? 0);
|
|
3167
|
-
out.cacheWrite = (out.cacheWrite ?? 0) + (t.cacheWrite ?? 0);
|
|
3168
|
-
}
|
|
3169
|
-
}
|
|
3170
|
-
return out;
|
|
3171
|
-
}
|
|
3172
|
-
|
|
3173
|
-
/** The share of all input that the prompt cache served, 0–1; null when
|
|
3174
|
-
* there was no input, or the record predates the split and cannot say. */
|
|
3175
|
-
export function cacheHitRate(t: TokenUsage | null | undefined): number | null {
|
|
3176
|
-
if (!t || t.cacheRead === undefined) return null;
|
|
3177
|
-
return t.input > 0 ? Math.min(1, t.cacheRead / t.input) : null;
|
|
3178
|
-
}
|
|
3179
|
-
|
|
3180
|
-
/** The cache share over several counts, taking only those that report the
|
|
3181
|
-
* split: a step recorded before it holds its cache traffic inside `input`,
|
|
3182
|
-
* and counting that as uncached would understate the rate. The rule the
|
|
3183
|
-
* Usage page and the CLI's totals follow too. */
|
|
3184
|
-
export function measuredHitRate(list: readonly (TokenUsage | null | undefined)[]): number | null {
|
|
3185
|
-
let read = 0;
|
|
3186
|
-
let all = 0;
|
|
3187
|
-
for (const t of list) {
|
|
3188
|
-
if (!t || t.cacheRead === undefined) continue;
|
|
3189
|
-
read += t.cacheRead;
|
|
3190
|
-
all += t.input;
|
|
3191
|
-
}
|
|
3192
|
-
return all > 0 ? read / all : null;
|
|
3193
|
-
}
|
|
3194
|
-
|
|
3195
|
-
/** A run's token counts, summed over its steps; null when no step reported any. */
|
|
3196
|
-
export function runTokens(run: { steps: readonly { tokens?: TokenUsage | null }[] }): TokenUsage | null {
|
|
3197
|
-
const toks = run.steps.map((s) => s.tokens).filter((t): t is TokenUsage => !!t);
|
|
3198
|
-
return toks.length ? sumTokens(toks) : null;
|
|
3199
|
-
}
|
|
3140
|
+
export { type TokenUsage, tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens } from "./tokens.ts";
|
|
3141
|
+
import { sumTokens, type TokenUsage } from "./tokens.ts";
|
|
3200
3142
|
|
|
3201
3143
|
export interface StepRecord {
|
|
3202
3144
|
agent: string;
|
|
@@ -3377,6 +3319,14 @@ export interface StepRecord {
|
|
|
3377
3319
|
* and not only "how much" — the price of a token changes, the count is
|
|
3378
3320
|
* the fact. Absent on steps recorded before this existed. */
|
|
3379
3321
|
tokens?: TokenUsage | null;
|
|
3322
|
+
/** Model turns, summed across attempts, and the system prompt's length in
|
|
3323
|
+
* characters — every turn sends it again. With the tool events' `bytes`,
|
|
3324
|
+
* what says where a step's input tokens went. Absent before 18 Oct 2026. */
|
|
3325
|
+
turns?: number | null;
|
|
3326
|
+
systemPromptChars?: number;
|
|
3327
|
+
/** Why the model's last turn ended (end_turn, max_tokens, tool_use…), as
|
|
3328
|
+
* the SDK's closing result says. */
|
|
3329
|
+
stopReason?: string | null;
|
|
3380
3330
|
/** What the sandbox actually touched, against what it reserved: CPU
|
|
3381
3331
|
* busy-seconds and peak memory from the pod's own cgroup, bytes on the
|
|
3382
3332
|
* wire from /proc/net/dev. Null per-field where the kernel interface
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Token counts and the prompt-cache share, as pure functions over values.
|
|
2
|
+
//
|
|
3
|
+
// No node imports: the dashboard's client components read these (the run
|
|
4
|
+
// page's step chips), and store.ts — fs, path, the tree reader — must never
|
|
5
|
+
// reach a browser bundle (web ae042d6 broke `next build` doing exactly that).
|
|
6
|
+
// store.ts re-exports everything here, so server code imports as before.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The token counts behind a step's cost. `input` is every input token the
|
|
10
|
+
* model saw; `cacheRead` and `cacheWrite` are the part of it the prompt
|
|
11
|
+
* cache served or stored — a read costs a tenth of fresh input, and a usage
|
|
12
|
+
* page wants to say how much of a step's context was re-read rather than
|
|
13
|
+
* re-sent. Steps recorded before 18 Oct 2026 have no cache fields: their
|
|
14
|
+
* `input` is the same total, but how much of it was cached is unknown.
|
|
15
|
+
*/
|
|
16
|
+
export interface TokenUsage {
|
|
17
|
+
input: number;
|
|
18
|
+
output: number;
|
|
19
|
+
cacheRead?: number;
|
|
20
|
+
cacheWrite?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The record's shape from what a step reports (ExecOutcome.usage). */
|
|
24
|
+
export function tokensOf(usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | null | undefined): TokenUsage | null {
|
|
25
|
+
if (!usage) return null;
|
|
26
|
+
return {
|
|
27
|
+
input: usage.inputTokens,
|
|
28
|
+
output: usage.outputTokens,
|
|
29
|
+
cacheRead: usage.cacheReadTokens ?? 0,
|
|
30
|
+
cacheWrite: usage.cacheWriteTokens ?? 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Field-by-field sum; the cache fields are present in the result whenever
|
|
35
|
+
* any input had them, so a run of old and new steps still reads whole. */
|
|
36
|
+
export function sumTokens(list: readonly TokenUsage[]): TokenUsage {
|
|
37
|
+
const out: TokenUsage = { input: 0, output: 0 };
|
|
38
|
+
for (const t of list) {
|
|
39
|
+
out.input += t.input;
|
|
40
|
+
out.output += t.output;
|
|
41
|
+
if (t.cacheRead !== undefined || t.cacheWrite !== undefined) {
|
|
42
|
+
out.cacheRead = (out.cacheRead ?? 0) + (t.cacheRead ?? 0);
|
|
43
|
+
out.cacheWrite = (out.cacheWrite ?? 0) + (t.cacheWrite ?? 0);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The share of all input that the prompt cache served, 0–1; null when
|
|
50
|
+
* there was no input, or the record predates the split and cannot say. */
|
|
51
|
+
export function cacheHitRate(t: TokenUsage | null | undefined): number | null {
|
|
52
|
+
if (!t || t.cacheRead === undefined) return null;
|
|
53
|
+
return t.input > 0 ? Math.min(1, t.cacheRead / t.input) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The cache share over several counts, taking only those that report the
|
|
57
|
+
* split: a step recorded before it holds its cache traffic inside `input`,
|
|
58
|
+
* and counting that as uncached would understate the rate. The rule the
|
|
59
|
+
* Usage page and the CLI's totals follow too. */
|
|
60
|
+
export function measuredHitRate(list: readonly (TokenUsage | null | undefined)[]): number | null {
|
|
61
|
+
let read = 0;
|
|
62
|
+
let all = 0;
|
|
63
|
+
for (const t of list) {
|
|
64
|
+
if (!t || t.cacheRead === undefined) continue;
|
|
65
|
+
read += t.cacheRead;
|
|
66
|
+
all += t.input;
|
|
67
|
+
}
|
|
68
|
+
return all > 0 ? read / all : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A run's token counts, summed over its steps; null when no step reported any. */
|
|
72
|
+
export function runTokens(run: { steps: readonly { tokens?: TokenUsage | null }[] }): TokenUsage | null {
|
|
73
|
+
const toks = run.steps.map((s) => s.tokens).filter((t): t is TokenUsage => !!t);
|
|
74
|
+
return toks.length ? sumTokens(toks) : null;
|
|
75
|
+
}
|