@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/src/runner.ts CHANGED
@@ -82,7 +82,8 @@ import {
82
82
  listAgents,
83
83
  runCost,
84
84
  } from "./store.ts";
85
- import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
85
+ import { canReadBack } from "./tool-output.ts";
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";
88
89
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.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
- const apiTools = buildApiTools(tenant, apis, workspace);
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;
@@ -2171,7 +2184,7 @@ async function runStep(
2171
2184
  // step on knowledge; unknown ids, presets and offline all pass through
2172
2185
  // to the model call, which stays the authority of last resort.
2173
2186
  let catalog: Catalog | null = null;
2174
- let tokenPrice: { input: number; output: number } | null = null;
2187
+ let tokenPrice: TokenPrice | null = null;
2175
2188
  // The env this run's model calls actually ride: the workspace's own
2176
2189
  // provider block when one is declared, otherwise the platform's — the
2177
2190
  // "models included" path, where the host env points every run at the
@@ -2197,10 +2210,7 @@ async function runStep(
2197
2210
  const verdict = checkModel(catalog, wireModel, { tools: needsTools });
2198
2211
  // Also what a token costs here, so a step can count its own spend
2199
2212
  // turn by turn against its ceiling instead of learning at the end.
2200
- const priced = findModel(catalog, wireModel);
2201
- if (priced && priced.promptPrice != null && priced.completionPrice != null) {
2202
- tokenPrice = { input: priced.promptPrice, output: priced.completionPrice };
2203
- }
2213
+ tokenPrice = tokenPriceOf(findModel(catalog, wireModel));
2204
2214
  if (!verdict.ok) {
2205
2215
  push("error", verdict.reason!);
2206
2216
  step.status = "failed";
@@ -2834,6 +2844,7 @@ async function runStep(
2834
2844
  status: "lost",
2835
2845
  costUsd: repriced(catalog, wireModel, first.usage ?? null, first.costUsd, push, first.turnsCostUsd),
2836
2846
  tokens: tokensOf(first.usage),
2847
+ ...(typeof first.turns === "number" ? { turns: first.turns } : {}),
2837
2848
  computeSecs: first.timing ? Math.round(first.timing.totalMs) / 1000 : null,
2838
2849
  finishedAt: new Date().toISOString(),
2839
2850
  error: decision.note.slice(0, 500),
@@ -2878,6 +2889,8 @@ async function runStep(
2878
2889
  if (outcome.data !== undefined) step.data = redactData(outcome.data);
2879
2890
  step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push, outcome.turnsCostUsd);
2880
2891
  step.tokens = tokensOf(outcome.usage);
2892
+ step.turns = typeof outcome.turns === "number" ? outcome.turns : null;
2893
+ step.stopReason = outcome.stopReason ?? null;
2881
2894
  step.actual = outcome.res ?? null;
2882
2895
  if (outcome.res?.peakMemBytes || outcome.res?.rxBytes) {
2883
2896
  const mb = (n: number | null) => (n === null ? "?" : (n / 1024 / 1024).toFixed(0));
@@ -3080,6 +3093,8 @@ async function runStep(
3080
3093
  const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push, outcome.turnsCostUsd);
3081
3094
  step.costUsd = stepCost === null && consultCost === 0 ? null : (stepCost ?? 0) + consultCost;
3082
3095
  step.tokens = tokensOf(outcome.usage);
3096
+ step.turns = typeof outcome.turns === "number" ? outcome.turns : null;
3097
+ step.stopReason = outcome.stopReason ?? null;
3083
3098
  for (const line of apiTools.drainLog()) push("info", `api: ${line}`);
3084
3099
  for (const line of scriptTools.drainLog()) push("info", `script: ${line}`);
3085
3100
  }
@@ -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
- const MAX_OUTPUT = 20_000;
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
@@ -15,7 +15,7 @@ import path from "node:path";
15
15
  import { query } from "@anthropic-ai/claude-agent-sdk";
16
16
  import type { HookInput, McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
17
17
  import type { Effort } from "./store.ts";
18
- import { CACHE_READ_MULTIPLIER, CACHE_WRITE_MULTIPLIER } from "./catalog.ts";
18
+ import { costOf, type TokenPrice } from "./catalog.ts";
19
19
  import type { TestEffect } from "./test-mode.ts";
20
20
  import type { OperatorEvent } from "./operator.ts";
21
21
  import { spawn } from "node:child_process";
@@ -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. */
@@ -119,7 +122,7 @@ export interface ExecOptions {
119
122
  /** Price per token for the model, when the catalogue knows it, so spend
120
123
  * can be counted turn by turn instead of learned at the end. Without it
121
124
  * the step prices its turns from the SDK's own running total. */
122
- price?: { input: number; output: number } | null;
125
+ price?: TokenPrice | null;
123
126
  /** A check the step must pass to count as done: a shell command that must
124
127
  * exit 0, or an eval-style assertion (`contains: x`, `not-contains: x`,
125
128
  * `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
@@ -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
@@ -229,29 +232,20 @@ export function knownPrice(model: string | undefined): { input: number; output:
229
232
  /**
230
233
  * What one assistant turn cost, from the usage the SDK reports. Pure.
231
234
  *
232
- * Cache reads and writes are priced at Anthropic's multipliers
233
- * (catalog.ts). Counting both at the full input rate — as this did —
235
+ * Cache reads and writes are priced at the model's own cache prices when
236
+ * the catalogue quotes them, else at Anthropic's multipliers (costOf in
237
+ * catalog.ts, the same arithmetic the record is repriced with). Counting
238
+ * both at the full input rate — as this did —
234
239
  * makes a long cached prompt read about ten times its price, which on a
235
240
  * ceiling is not "the honest direction to be wrong in", it is a step
236
241
  * killed for spending money it never spent.
237
242
  *
238
- * A gateway whose cache is priced differently is over- or under-counted
239
- * here by that difference alone; the run record's own cost is repriced
240
- * from the catalogue afterwards and stays the authority on the bill. This
241
- * number exists to stop a runaway step mid-turn, and wants to be close.
243
+ * The run record's own cost is repriced from the catalogue afterwards and
244
+ * stays the authority on the bill. This number exists to stop a runaway
245
+ * step mid-turn, and wants to be close.
242
246
  */
243
- export function priceTurn(u: Record<string, number | undefined>, price: { input: number; output: number } | null): number {
244
- const p = price ?? FALLBACK_PRICE;
245
- const fresh = u.input_tokens ?? 0;
246
- const written = u.cache_creation_input_tokens ?? 0;
247
- const read = u.cache_read_input_tokens ?? 0;
248
- const outTok = u.output_tokens ?? 0;
249
- return (
250
- fresh * p.input +
251
- written * p.input * CACHE_WRITE_MULTIPLIER +
252
- read * p.input * CACHE_READ_MULTIPLIER +
253
- outTok * p.output
254
- );
247
+ export function priceTurn(u: Record<string, number | undefined>, price: TokenPrice | null): number {
248
+ return costOf(usageFromSdk(u), price ?? FALLBACK_PRICE);
255
249
  }
256
250
 
257
251
  /**
@@ -321,6 +315,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
321
315
  let costUsd: number | null = null;
322
316
  let usage: ExecOutcome["usage"] = null;
323
317
  let stopReason: string | null = null;
318
+ let numTurns: number | null = null;
324
319
  const texts: string[] = [];
325
320
 
326
321
  fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
@@ -633,10 +628,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
633
628
  // runs its turns on that model, and pricing them at the parent's put a
634
629
  // max sub-agent under a fast parent at a fifth of its cost — against the
635
630
  // ceiling and on the bill.
636
- const turns = new Map<string, { u: Record<string, number | undefined>; price: { input: number; output: number } | null }>();
631
+ const turns = new Map<string, { u: Record<string, number | undefined>; price: TokenPrice | null }>();
637
632
  let anonymousTurns = 0;
638
633
  const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
639
- const priceOf = (message: unknown): { input: number; output: number } | null => {
634
+ const priceOf = (message: unknown): TokenPrice | null => {
640
635
  const parent = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id;
641
636
  if (!parent) return opts.price ?? null;
642
637
  const declared = specByName.get(delegations.get(parent) ?? "")?.model;
@@ -758,6 +753,9 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
758
753
  emit("tool", open.name, {
759
754
  call: block.tool_use_id,
760
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,
761
759
  ...(block.is_error ? { err: true } : {}),
762
760
  ...(open.subagent ? { subagent: open.subagent } : {}),
763
761
  });
@@ -783,6 +781,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
783
781
  }
784
782
  costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
785
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;
786
785
  if ("usage" in message && message.usage) {
787
786
  // Cache reads and writes counted beside the input total: with only
788
787
  // the total (until 18 Oct 2026) the hit rate could not be read and
@@ -950,6 +949,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
950
949
  status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage,
951
950
  ...(mixedModels() ? { turnsCostUsd: turnsTotal() } : {}),
952
951
  stopReason,
952
+ turns: numTurns ?? turns.size,
953
953
  ...(pod ? { browserPod: podState } : {}),
954
954
  };
955
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
- * The token counts behind a step's cost. `input` is every input token the
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
+ }
@@ -0,0 +1,82 @@
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
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import crypto from "node:crypto";
18
+
19
+ /** What the model sees inline, at most: about 3,000 tokens. */
20
+ export const RESULT_PREVIEW_CHARS = 12_000;
21
+
22
+ /** What a tool's output is collected up to before it is kept — enough for a
23
+ * big API page or a long script log, bounded so a runaway stream cannot
24
+ * fill the sandbox's memory. */
25
+ export const RESULT_KEEP_CHARS = 2_000_000;
26
+
27
+ /** The results folder, relative to the agent's working directory. */
28
+ export const RESULTS_DIR = ".cache/results";
29
+
30
+ /** What an agent that cannot read files gets: the whole result up to the
31
+ * old cut, as before 18 Oct 2026 — a pointer to a file it has no tool to
32
+ * open would only lose it the tail. */
33
+ export const UNREADABLE_CAP = 20_000;
34
+
35
+ /** Does a step granted these tools hold one that reads a kept result back? */
36
+ export function canReadBack(allowed: readonly string[]): boolean {
37
+ return allowed.includes("Read") || allowed.includes("Grep");
38
+ }
39
+
40
+ /** Where a long result may be kept: the agent's folder, and whether the agent
41
+ * holds a tool to read it back (Read or Grep). Asked per call, because the
42
+ * runner builds the tools before it settles the agent's grants. */
43
+ export interface KeepIn {
44
+ dir: string;
45
+ readable: () => boolean;
46
+ }
47
+
48
+ /**
49
+ * `text` as the model should see it: unchanged when short; otherwise its
50
+ * head — cut at a line break when one falls in the last fifth — and where
51
+ * the whole of it is. `label` names the file (the tool's name). An agent
52
+ * that cannot read files back, or no folder to keep it in: the whole result
53
+ * up to UNREADABLE_CAP, as it always was.
54
+ */
55
+ export function previewOrKeep(
56
+ text: string,
57
+ keep: KeepIn | null | undefined,
58
+ label: string,
59
+ cap: number = RESULT_PREVIEW_CHARS,
60
+ ): string {
61
+ const whole = () => (text.length > UNREADABLE_CAP ? `${text.slice(0, UNREADABLE_CAP)}\n…[truncated]` : text);
62
+ if (!keep || !keep.readable()) return whole();
63
+ const agentDir = keep.dir;
64
+ if (text.length <= cap) return text;
65
+ let cut = text.lastIndexOf("\n", cap);
66
+ if (cut < cap * 0.8) cut = cap;
67
+ const head = text.slice(0, cut);
68
+ const more = text.length - cut;
69
+ const name = `${label.replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 60)}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}.txt`;
70
+ const rel = path.posix.join(RESULTS_DIR, name);
71
+ try {
72
+ fs.mkdirSync(path.join(agentDir, RESULTS_DIR), { recursive: true });
73
+ fs.writeFileSync(path.join(agentDir, rel), text);
74
+ } catch {
75
+ // Nowhere to keep it: what it would have had before, rather than a tail lost.
76
+ return whole();
77
+ }
78
+ return (
79
+ `${head}\n…(${more} more characters not shown — the whole result, ${text.length} characters, is in \`${rel}\`. ` +
80
+ `Read it with offset/limit, or Grep it, if you need what is past this point.)`
81
+ );
82
+ }