@foldrun/core 2.1.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/catalog.d.ts +35 -2
- package/dist/src/catalog.js +39 -12
- package/dist/src/run-container.d.ts +6 -4
- package/dist/src/run-container.js +9 -1
- package/dist/src/runner.js +22 -7
- package/dist/src/script-tools.d.ts +4 -1
- package/dist/src/script-tools.js +9 -3
- package/dist/src/step-exec.d.ts +14 -14
- package/dist/src/step-exec.js +16 -16
- 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/catalog.ts +60 -12
- package/src/run-container.ts +15 -3
- package/src/runner.ts +22 -7
- package/src/script-tools.ts +8 -2
- package/src/step-exec.ts +23 -23
- package/src/store.ts +19 -69
- package/src/tokens.ts +75 -0
- package/src/tool-output.ts +82 -0
package/dist/src/store.d.ts
CHANGED
|
@@ -893,6 +893,10 @@ export interface RunEvent {
|
|
|
893
893
|
call?: string;
|
|
894
894
|
ms?: number;
|
|
895
895
|
err?: boolean;
|
|
896
|
+
/** On a tool completion: the result's length in characters — what it put
|
|
897
|
+
* into the conversation, which every later turn sends again. Absent before
|
|
898
|
+
* 18 Oct 2026. */
|
|
899
|
+
bytes?: number;
|
|
896
900
|
/** Set on a test run's event that reports what the platform did instead
|
|
897
901
|
* of the real thing — a refused send, a redirected email, a diverted
|
|
898
902
|
* state write, a withheld secret. The run page lists these; the text is
|
|
@@ -954,6 +958,8 @@ export interface StepAttempt {
|
|
|
954
958
|
status: "completed" | "failed" | "lost";
|
|
955
959
|
costUsd: number | null;
|
|
956
960
|
tokens: TokenUsage | null;
|
|
961
|
+
/** Model turns this attempt took; absent before 18 Oct 2026. */
|
|
962
|
+
turns?: number | null;
|
|
957
963
|
computeSecs: number | null;
|
|
958
964
|
startedAt: string;
|
|
959
965
|
finishedAt: string;
|
|
@@ -968,45 +974,9 @@ export interface StepAttempt {
|
|
|
968
974
|
/** The step's totals across its attempts, from the rows. Null where no
|
|
969
975
|
* attempt reported the figure — an in-process step rents no sandbox, and
|
|
970
976
|
* billing reads null as zero, not as "unknown, charge something". */
|
|
971
|
-
export declare function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs">;
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
* model saw; `cacheRead` and `cacheWrite` are the part of it the prompt
|
|
975
|
-
* cache served or stored — a read costs a tenth of fresh input, and a usage
|
|
976
|
-
* page wants to say how much of a step's context was re-read rather than
|
|
977
|
-
* re-sent. Steps recorded before 18 Oct 2026 have no cache fields: their
|
|
978
|
-
* `input` is the same total, but how much of it was cached is unknown.
|
|
979
|
-
*/
|
|
980
|
-
export interface TokenUsage {
|
|
981
|
-
input: number;
|
|
982
|
-
output: number;
|
|
983
|
-
cacheRead?: number;
|
|
984
|
-
cacheWrite?: number;
|
|
985
|
-
}
|
|
986
|
-
/** The record's shape from what a step reports (ExecOutcome.usage). */
|
|
987
|
-
export declare function tokensOf(usage: {
|
|
988
|
-
inputTokens: number;
|
|
989
|
-
outputTokens: number;
|
|
990
|
-
cacheReadTokens?: number;
|
|
991
|
-
cacheWriteTokens?: number;
|
|
992
|
-
} | null | undefined): TokenUsage | null;
|
|
993
|
-
/** Field-by-field sum; the cache fields are present in the result whenever
|
|
994
|
-
* any input had them, so a run of old and new steps still reads whole. */
|
|
995
|
-
export declare function sumTokens(list: readonly TokenUsage[]): TokenUsage;
|
|
996
|
-
/** The share of all input that the prompt cache served, 0–1; null when
|
|
997
|
-
* there was no input, or the record predates the split and cannot say. */
|
|
998
|
-
export declare function cacheHitRate(t: TokenUsage | null | undefined): number | null;
|
|
999
|
-
/** The cache share over several counts, taking only those that report the
|
|
1000
|
-
* split: a step recorded before it holds its cache traffic inside `input`,
|
|
1001
|
-
* and counting that as uncached would understate the rate. The rule the
|
|
1002
|
-
* Usage page and the CLI's totals follow too. */
|
|
1003
|
-
export declare function measuredHitRate(list: readonly (TokenUsage | null | undefined)[]): number | null;
|
|
1004
|
-
/** A run's token counts, summed over its steps; null when no step reported any. */
|
|
1005
|
-
export declare function runTokens(run: {
|
|
1006
|
-
steps: readonly {
|
|
1007
|
-
tokens?: TokenUsage | null;
|
|
1008
|
-
}[];
|
|
1009
|
-
}): TokenUsage | null;
|
|
977
|
+
export declare function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs" | "turns">;
|
|
978
|
+
export { type TokenUsage, tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens } from "./tokens.ts";
|
|
979
|
+
import { type TokenUsage } from "./tokens.ts";
|
|
1010
980
|
export interface StepRecord {
|
|
1011
981
|
agent: string;
|
|
1012
982
|
instruction: string;
|
|
@@ -1195,6 +1165,14 @@ export interface StepRecord {
|
|
|
1195
1165
|
* and not only "how much" — the price of a token changes, the count is
|
|
1196
1166
|
* the fact. Absent on steps recorded before this existed. */
|
|
1197
1167
|
tokens?: TokenUsage | null;
|
|
1168
|
+
/** Model turns, summed across attempts, and the system prompt's length in
|
|
1169
|
+
* characters — every turn sends it again. With the tool events' `bytes`,
|
|
1170
|
+
* what says where a step's input tokens went. Absent before 18 Oct 2026. */
|
|
1171
|
+
turns?: number | null;
|
|
1172
|
+
systemPromptChars?: number;
|
|
1173
|
+
/** Why the model's last turn ended (end_turn, max_tokens, tool_use…), as
|
|
1174
|
+
* the SDK's closing result says. */
|
|
1175
|
+
stopReason?: string | null;
|
|
1198
1176
|
/** What the sandbox actually touched, against what it reserved: CPU
|
|
1199
1177
|
* busy-seconds and peak memory from the pod's own cgroup, bytes on the
|
|
1200
1178
|
* wire from /proc/net/dev. Null per-field where the kernel interface
|
package/dist/src/store.js
CHANGED
|
@@ -2573,66 +2573,18 @@ export function flowPatternTemplate(pattern, name, agents) {
|
|
|
2573
2573
|
* billing reads null as zero, not as "unknown, charge something". */
|
|
2574
2574
|
export function sumAttempts(tries) {
|
|
2575
2575
|
const costs = tries.map((t) => t.costUsd).filter((c) => typeof c === "number");
|
|
2576
|
+
const turns = tries.map((t) => t.turns).filter((c) => typeof c === "number");
|
|
2576
2577
|
const toks = tries.map((t) => t.tokens).filter((t) => !!t);
|
|
2577
2578
|
const secs = tries.map((t) => t.computeSecs).filter((c) => typeof c === "number");
|
|
2578
2579
|
return {
|
|
2579
2580
|
costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
|
|
2580
2581
|
tokens: toks.length ? sumTokens(toks) : null,
|
|
2581
2582
|
computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
|
|
2583
|
+
...(turns.length ? { turns: turns.reduce((a, b) => a + b, 0) } : {}),
|
|
2582
2584
|
};
|
|
2583
2585
|
}
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
if (!usage)
|
|
2587
|
-
return null;
|
|
2588
|
-
return {
|
|
2589
|
-
input: usage.inputTokens,
|
|
2590
|
-
output: usage.outputTokens,
|
|
2591
|
-
cacheRead: usage.cacheReadTokens ?? 0,
|
|
2592
|
-
cacheWrite: usage.cacheWriteTokens ?? 0,
|
|
2593
|
-
};
|
|
2594
|
-
}
|
|
2595
|
-
/** Field-by-field sum; the cache fields are present in the result whenever
|
|
2596
|
-
* any input had them, so a run of old and new steps still reads whole. */
|
|
2597
|
-
export function sumTokens(list) {
|
|
2598
|
-
const out = { input: 0, output: 0 };
|
|
2599
|
-
for (const t of list) {
|
|
2600
|
-
out.input += t.input;
|
|
2601
|
-
out.output += t.output;
|
|
2602
|
-
if (t.cacheRead !== undefined || t.cacheWrite !== undefined) {
|
|
2603
|
-
out.cacheRead = (out.cacheRead ?? 0) + (t.cacheRead ?? 0);
|
|
2604
|
-
out.cacheWrite = (out.cacheWrite ?? 0) + (t.cacheWrite ?? 0);
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
|
-
return out;
|
|
2608
|
-
}
|
|
2609
|
-
/** The share of all input that the prompt cache served, 0–1; null when
|
|
2610
|
-
* there was no input, or the record predates the split and cannot say. */
|
|
2611
|
-
export function cacheHitRate(t) {
|
|
2612
|
-
if (!t || t.cacheRead === undefined)
|
|
2613
|
-
return null;
|
|
2614
|
-
return t.input > 0 ? Math.min(1, t.cacheRead / t.input) : null;
|
|
2615
|
-
}
|
|
2616
|
-
/** The cache share over several counts, taking only those that report the
|
|
2617
|
-
* split: a step recorded before it holds its cache traffic inside `input`,
|
|
2618
|
-
* and counting that as uncached would understate the rate. The rule the
|
|
2619
|
-
* Usage page and the CLI's totals follow too. */
|
|
2620
|
-
export function measuredHitRate(list) {
|
|
2621
|
-
let read = 0;
|
|
2622
|
-
let all = 0;
|
|
2623
|
-
for (const t of list) {
|
|
2624
|
-
if (!t || t.cacheRead === undefined)
|
|
2625
|
-
continue;
|
|
2626
|
-
read += t.cacheRead;
|
|
2627
|
-
all += t.input;
|
|
2628
|
-
}
|
|
2629
|
-
return all > 0 ? read / all : null;
|
|
2630
|
-
}
|
|
2631
|
-
/** A run's token counts, summed over its steps; null when no step reported any. */
|
|
2632
|
-
export function runTokens(run) {
|
|
2633
|
-
const toks = run.steps.map((s) => s.tokens).filter((t) => !!t);
|
|
2634
|
-
return toks.length ? sumTokens(toks) : null;
|
|
2635
|
-
}
|
|
2586
|
+
export { tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens } from "./tokens.js";
|
|
2587
|
+
import { sumTokens } from "./tokens.js";
|
|
2636
2588
|
export function runFilePath(tenant, workspace, runId) {
|
|
2637
2589
|
assertSafeName(runId, "run id");
|
|
2638
2590
|
return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token counts behind a step's cost. `input` is every input token the
|
|
3
|
+
* model saw; `cacheRead` and `cacheWrite` are the part of it the prompt
|
|
4
|
+
* cache served or stored — a read costs a tenth of fresh input, and a usage
|
|
5
|
+
* page wants to say how much of a step's context was re-read rather than
|
|
6
|
+
* re-sent. Steps recorded before 18 Oct 2026 have no cache fields: their
|
|
7
|
+
* `input` is the same total, but how much of it was cached is unknown.
|
|
8
|
+
*/
|
|
9
|
+
export interface TokenUsage {
|
|
10
|
+
input: number;
|
|
11
|
+
output: number;
|
|
12
|
+
cacheRead?: number;
|
|
13
|
+
cacheWrite?: number;
|
|
14
|
+
}
|
|
15
|
+
/** The record's shape from what a step reports (ExecOutcome.usage). */
|
|
16
|
+
export declare function tokensOf(usage: {
|
|
17
|
+
inputTokens: number;
|
|
18
|
+
outputTokens: number;
|
|
19
|
+
cacheReadTokens?: number;
|
|
20
|
+
cacheWriteTokens?: number;
|
|
21
|
+
} | null | undefined): TokenUsage | null;
|
|
22
|
+
/** Field-by-field sum; the cache fields are present in the result whenever
|
|
23
|
+
* any input had them, so a run of old and new steps still reads whole. */
|
|
24
|
+
export declare function sumTokens(list: readonly TokenUsage[]): TokenUsage;
|
|
25
|
+
/** The share of all input that the prompt cache served, 0–1; null when
|
|
26
|
+
* there was no input, or the record predates the split and cannot say. */
|
|
27
|
+
export declare function cacheHitRate(t: TokenUsage | null | undefined): number | null;
|
|
28
|
+
/** The cache share over several counts, taking only those that report the
|
|
29
|
+
* split: a step recorded before it holds its cache traffic inside `input`,
|
|
30
|
+
* and counting that as uncached would understate the rate. The rule the
|
|
31
|
+
* Usage page and the CLI's totals follow too. */
|
|
32
|
+
export declare function measuredHitRate(list: readonly (TokenUsage | null | undefined)[]): number | null;
|
|
33
|
+
/** A run's token counts, summed over its steps; null when no step reported any. */
|
|
34
|
+
export declare function runTokens(run: {
|
|
35
|
+
steps: readonly {
|
|
36
|
+
tokens?: TokenUsage | null;
|
|
37
|
+
}[];
|
|
38
|
+
}): TokenUsage | null;
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
/** The record's shape from what a step reports (ExecOutcome.usage). */
|
|
8
|
+
export function tokensOf(usage) {
|
|
9
|
+
if (!usage)
|
|
10
|
+
return null;
|
|
11
|
+
return {
|
|
12
|
+
input: usage.inputTokens,
|
|
13
|
+
output: usage.outputTokens,
|
|
14
|
+
cacheRead: usage.cacheReadTokens ?? 0,
|
|
15
|
+
cacheWrite: usage.cacheWriteTokens ?? 0,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Field-by-field sum; the cache fields are present in the result whenever
|
|
19
|
+
* any input had them, so a run of old and new steps still reads whole. */
|
|
20
|
+
export function sumTokens(list) {
|
|
21
|
+
const out = { input: 0, output: 0 };
|
|
22
|
+
for (const t of list) {
|
|
23
|
+
out.input += t.input;
|
|
24
|
+
out.output += t.output;
|
|
25
|
+
if (t.cacheRead !== undefined || t.cacheWrite !== undefined) {
|
|
26
|
+
out.cacheRead = (out.cacheRead ?? 0) + (t.cacheRead ?? 0);
|
|
27
|
+
out.cacheWrite = (out.cacheWrite ?? 0) + (t.cacheWrite ?? 0);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** The share of all input that the prompt cache served, 0–1; null when
|
|
33
|
+
* there was no input, or the record predates the split and cannot say. */
|
|
34
|
+
export function cacheHitRate(t) {
|
|
35
|
+
if (!t || t.cacheRead === undefined)
|
|
36
|
+
return null;
|
|
37
|
+
return t.input > 0 ? Math.min(1, t.cacheRead / t.input) : null;
|
|
38
|
+
}
|
|
39
|
+
/** The cache share over several counts, taking only those that report the
|
|
40
|
+
* split: a step recorded before it holds its cache traffic inside `input`,
|
|
41
|
+
* and counting that as uncached would understate the rate. The rule the
|
|
42
|
+
* Usage page and the CLI's totals follow too. */
|
|
43
|
+
export function measuredHitRate(list) {
|
|
44
|
+
let read = 0;
|
|
45
|
+
let all = 0;
|
|
46
|
+
for (const t of list) {
|
|
47
|
+
if (!t || t.cacheRead === undefined)
|
|
48
|
+
continue;
|
|
49
|
+
read += t.cacheRead;
|
|
50
|
+
all += t.input;
|
|
51
|
+
}
|
|
52
|
+
return all > 0 ? read / all : null;
|
|
53
|
+
}
|
|
54
|
+
/** A run's token counts, summed over its steps; null when no step reported any. */
|
|
55
|
+
export function runTokens(run) {
|
|
56
|
+
const toks = run.steps.map((s) => s.tokens).filter((t) => !!t);
|
|
57
|
+
return toks.length ? sumTokens(toks) : null;
|
|
58
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** What the model sees inline, at most: about 3,000 tokens. */
|
|
2
|
+
export declare const RESULT_PREVIEW_CHARS = 12000;
|
|
3
|
+
/** What a tool's output is collected up to before it is kept — enough for a
|
|
4
|
+
* big API page or a long script log, bounded so a runaway stream cannot
|
|
5
|
+
* fill the sandbox's memory. */
|
|
6
|
+
export declare const RESULT_KEEP_CHARS = 2000000;
|
|
7
|
+
/** The results folder, relative to the agent's working directory. */
|
|
8
|
+
export declare const RESULTS_DIR = ".cache/results";
|
|
9
|
+
/** What an agent that cannot read files gets: the whole result up to the
|
|
10
|
+
* old cut, as before 18 Oct 2026 — a pointer to a file it has no tool to
|
|
11
|
+
* open would only lose it the tail. */
|
|
12
|
+
export declare const UNREADABLE_CAP = 20000;
|
|
13
|
+
/** Does a step granted these tools hold one that reads a kept result back? */
|
|
14
|
+
export declare function canReadBack(allowed: readonly string[]): boolean;
|
|
15
|
+
/** Where a long result may be kept: the agent's folder, and whether the agent
|
|
16
|
+
* holds a tool to read it back (Read or Grep). Asked per call, because the
|
|
17
|
+
* runner builds the tools before it settles the agent's grants. */
|
|
18
|
+
export interface KeepIn {
|
|
19
|
+
dir: string;
|
|
20
|
+
readable: () => boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `text` as the model should see it: unchanged when short; otherwise its
|
|
24
|
+
* head — cut at a line break when one falls in the last fifth — and where
|
|
25
|
+
* the whole of it is. `label` names the file (the tool's name). An agent
|
|
26
|
+
* that cannot read files back, or no folder to keep it in: the whole result
|
|
27
|
+
* up to UNREADABLE_CAP, as it always was.
|
|
28
|
+
*/
|
|
29
|
+
export declare function previewOrKeep(text: string, keep: KeepIn | null | undefined, label: string, cap?: number): string;
|
|
@@ -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/catalog.ts
CHANGED
|
@@ -34,6 +34,13 @@ export interface CatalogModel {
|
|
|
34
34
|
promptPrice: number | null;
|
|
35
35
|
/** USD per output token. */
|
|
36
36
|
completionPrice: number | null;
|
|
37
|
+
/** USD per input token read from / written to the prompt cache, when the
|
|
38
|
+
* gateway quotes them (OpenRouter's input_cache_read / input_cache_write).
|
|
39
|
+
* Models discount their cache differently — GPT-4o reads at half the
|
|
40
|
+
* input price, Claude at a tenth — so a quoted price beats any multiplier.
|
|
41
|
+
* Absent on catalogues cached before 2.1.1. */
|
|
42
|
+
cacheReadPrice?: number | null;
|
|
43
|
+
cacheWritePrice?: number | null;
|
|
37
44
|
/** Can this model make tool calls? Null when the gateway doesn't say. */
|
|
38
45
|
tools: boolean | null;
|
|
39
46
|
/** Does it take a reasoning-depth parameter? (What `effort:` needs.) */
|
|
@@ -63,7 +70,8 @@ const cacheFile = (baseUrl: string) =>
|
|
|
63
70
|
`${new URL(baseUrl).hostname.replace(/[^a-z0-9.-]/gi, "_")}.json`,
|
|
64
71
|
);
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
/** The gateway's /models payload as entries. Exported for its tests. */
|
|
74
|
+
export function parseModels(payload: unknown): CatalogModel[] {
|
|
67
75
|
const data = (payload as { data?: unknown[] })?.data;
|
|
68
76
|
if (!Array.isArray(data)) return [];
|
|
69
77
|
const out: CatalogModel[] = [];
|
|
@@ -75,8 +83,13 @@ function parseModels(payload: unknown): CatalogModel[] {
|
|
|
75
83
|
? m.supported_parameters.map(String)
|
|
76
84
|
: null;
|
|
77
85
|
const pricing = (m.pricing ?? {}) as Record<string, unknown>;
|
|
86
|
+
// Unquoted is unknown, not free: Number(null) and Number("") are 0, and
|
|
87
|
+
// a gateway answering `input_cache_read: null` would have made caching
|
|
88
|
+
// cost nothing. An explicit "0" stays 0.
|
|
78
89
|
const price = (k: string) => {
|
|
79
|
-
const
|
|
90
|
+
const v = pricing[k];
|
|
91
|
+
if (v == null || (typeof v === "string" && v.trim() === "")) return null;
|
|
92
|
+
const n = Number(v);
|
|
80
93
|
return Number.isFinite(n) ? n : null;
|
|
81
94
|
};
|
|
82
95
|
out.push({
|
|
@@ -85,6 +98,8 @@ function parseModels(payload: unknown): CatalogModel[] {
|
|
|
85
98
|
contextLength: Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : null,
|
|
86
99
|
promptPrice: price("prompt"),
|
|
87
100
|
completionPrice: price("completion"),
|
|
101
|
+
cacheReadPrice: price("input_cache_read"),
|
|
102
|
+
cacheWritePrice: price("input_cache_write"),
|
|
88
103
|
tools: supported ? supported.includes("tools") : null,
|
|
89
104
|
reasoning: supported ? supported.includes("reasoning") : null,
|
|
90
105
|
efforts: Array.isArray((m.reasoning as Record<string, unknown> | undefined)?.supported_efforts)
|
|
@@ -249,22 +264,55 @@ export function catalogCost(
|
|
|
249
264
|
): number | null {
|
|
250
265
|
const m = findModel(catalog, modelId);
|
|
251
266
|
if (!m || m.promptPrice == null || m.completionPrice == null) return null;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
return costOf(usage, tokenPriceOf(m)!);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** What a token costs on one model: input, output, and — when the gateway
|
|
271
|
+
* quotes them — reads from and writes to the prompt cache. */
|
|
272
|
+
export interface TokenPrice {
|
|
273
|
+
input: number;
|
|
274
|
+
output: number;
|
|
275
|
+
cacheRead?: number | null;
|
|
276
|
+
cacheWrite?: number | null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A catalogue entry's prices, or null when it has no input/output price. */
|
|
280
|
+
export function tokenPriceOf(m: CatalogModel | null | undefined): TokenPrice | null {
|
|
281
|
+
if (!m || m.promptPrice == null || m.completionPrice == null) return null;
|
|
282
|
+
return {
|
|
283
|
+
input: m.promptPrice,
|
|
284
|
+
output: m.completionPrice,
|
|
285
|
+
...(m.cacheReadPrice != null ? { cacheRead: m.cacheReadPrice } : {}),
|
|
286
|
+
...(m.cacheWritePrice != null ? { cacheWrite: m.cacheWritePrice } : {}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The cost of a set of counts at a price. `inputTokens` is all input; the
|
|
292
|
+
* cache fields are parts of it. Cached input is priced at the model's own
|
|
293
|
+
* cache prices when the gateway quotes them, and only otherwise at
|
|
294
|
+
* Anthropic's multipliers over the input price. Before the counts were
|
|
295
|
+
* split (18 Oct 2026) every cached token was billed at the full rate.
|
|
296
|
+
*/
|
|
297
|
+
export function costOf(
|
|
298
|
+
usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
|
|
299
|
+
p: TokenPrice,
|
|
300
|
+
): number {
|
|
258
301
|
const read = usage.cacheReadTokens ?? 0;
|
|
259
302
|
const written = usage.cacheWriteTokens ?? 0;
|
|
260
303
|
const fresh = Math.max(0, usage.inputTokens - read - written);
|
|
261
|
-
|
|
262
|
-
|
|
304
|
+
return (
|
|
305
|
+
fresh * p.input +
|
|
306
|
+
written * (p.cacheWrite ?? p.input * CACHE_WRITE_MULTIPLIER) +
|
|
307
|
+
read * (p.cacheRead ?? p.input * CACHE_READ_MULTIPLIER) +
|
|
308
|
+
usage.outputTokens * p.output
|
|
309
|
+
);
|
|
263
310
|
}
|
|
264
311
|
|
|
265
312
|
/** Anthropic's cache multipliers over the input price: a write costs a
|
|
266
|
-
* quarter more than fresh input, a read a tenth of it.
|
|
267
|
-
* mid-step ceiling (priceTurn) and
|
|
313
|
+
* quarter more than fresh input, a read a tenth of it. The fallback when a
|
|
314
|
+
* gateway quotes no cache price — for the mid-step ceiling (priceTurn) and
|
|
315
|
+
* the record's repricing (catalogCost) alike. */
|
|
268
316
|
export const CACHE_WRITE_MULTIPLIER = 1.25;
|
|
269
317
|
export const CACHE_READ_MULTIPLIER = 0.1;
|
|
270
318
|
|
package/src/run-container.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
|
28
28
|
import { isPlatformPath, type ApiSpec, type Effort } from "./store.ts";
|
|
29
29
|
import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.ts";
|
|
30
30
|
import type { EventExtra, ExecUsage } from "./step-exec.ts";
|
|
31
|
+
import type { TokenPrice } from "./catalog.ts";
|
|
31
32
|
import { isFileValue, fileContent } from "./secrets.ts";
|
|
32
33
|
import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET, type RuntimeSpec } from "./runtime.ts";
|
|
33
34
|
import { egressCaSh, type NetworkLevel } from "./network.ts";
|
|
@@ -69,7 +70,7 @@ export interface ContainerStepInput {
|
|
|
69
70
|
* step-exec.ts. Values, so the driver can enforce them in the pod. */
|
|
70
71
|
budgetUsd?: number | null;
|
|
71
72
|
budgetNote?: string;
|
|
72
|
-
price?:
|
|
73
|
+
price?: TokenPrice | null;
|
|
73
74
|
verify?: string;
|
|
74
75
|
/** `output: json` — checked inside, where the reply is. */
|
|
75
76
|
output?: "json";
|
|
@@ -185,6 +186,9 @@ export interface ContainerStepOutcome {
|
|
|
185
186
|
data?: unknown;
|
|
186
187
|
costUsd: number | null;
|
|
187
188
|
usage?: ExecUsage | null;
|
|
189
|
+
/** Model turns, and why the last one ended — see ExecOutcome. */
|
|
190
|
+
turns?: number;
|
|
191
|
+
stopReason?: string | null;
|
|
188
192
|
/** Every turn priced at its own model, when sub-agents ran on others —
|
|
189
193
|
* see ExecOutcome.turnsCostUsd. */
|
|
190
194
|
turnsCostUsd?: number;
|
|
@@ -671,13 +675,18 @@ try {
|
|
|
671
675
|
// buildApiTools gets an already-resolved environment. verify: gets the
|
|
672
676
|
// container's own environment, whole: it is the boundary, assembled on
|
|
673
677
|
// the host from an allowlist, and the declared secrets are in it.
|
|
674
|
-
|
|
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);
|
|
675
682
|
const script = buildScriptTools(
|
|
676
683
|
agentDir,
|
|
677
684
|
input.scripts,
|
|
678
685
|
{ ...env, ...runtime.env },
|
|
679
686
|
"/library/scripts",
|
|
680
687
|
runtime.interpreters,
|
|
688
|
+
null,
|
|
689
|
+
keepIn.readable,
|
|
681
690
|
);
|
|
682
691
|
// The translator, when the provider speaks Chat Completions: a loopback
|
|
683
692
|
// server for the length of this step. Its env replaces the endpoint the
|
|
@@ -1183,7 +1192,7 @@ export function buildFailureSummary(log: string, opts: { tail?: number; maxLines
|
|
|
1183
1192
|
|
|
1184
1193
|
export function parseDriverLine(
|
|
1185
1194
|
line: string,
|
|
1186
|
-
): { 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 {
|
|
1187
1196
|
const trimmed = line.trim();
|
|
1188
1197
|
if (!trimmed.startsWith("{")) return null;
|
|
1189
1198
|
try {
|
|
@@ -1196,6 +1205,7 @@ export function parseDriverLine(
|
|
|
1196
1205
|
...(typeof parsed.call === "string" ? { call: parsed.call } : {}),
|
|
1197
1206
|
...(typeof parsed.ms === "number" ? { ms: parsed.ms } : {}),
|
|
1198
1207
|
...(parsed.err === true ? { err: true } : {}),
|
|
1208
|
+
...(typeof parsed.bytes === "number" && parsed.bytes >= 0 ? { bytes: parsed.bytes } : {}),
|
|
1199
1209
|
...(isOperatorEvent(parsed.operator) ? { operator: parsed.operator } : {}),
|
|
1200
1210
|
...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
|
|
1201
1211
|
...(parsed.check === true ? { check: true } : {}),
|
|
@@ -1211,6 +1221,8 @@ export function parseDriverLine(
|
|
|
1211
1221
|
...("data" in parsed ? { data: parsed.data } : {}),
|
|
1212
1222
|
costUsd: typeof parsed.costUsd === "number" ? parsed.costUsd : null,
|
|
1213
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) } : {}),
|
|
1214
1226
|
...(parseBrowserPod(parsed.browserPod) ? { browserPod: parseBrowserPod(parsed.browserPod)! } : {}),
|
|
1215
1227
|
usage:
|
|
1216
1228
|
parsed.usage &&
|