@foldrun/core 2.2.0 → 2.4.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/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
- /** The record's shape from what a step reports (ExecOutcome.usage). */
2585
- export function tokensOf(usage) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
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
- const MAX_BODY_CHARS = 20_000;
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 truncated =
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/evals.ts CHANGED
@@ -31,7 +31,8 @@
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
33
 
34
- import { accountModelEnv } from "./model-env.ts";
34
+ import { accountModelEnv, withModelEnv } from "./model-env.ts";
35
+ import type { TranslatorSpec } from "./translator.ts";
35
36
  import { sdkEnv } from "./step-exec.ts";
36
37
  import fs from "node:fs";
37
38
  import path from "node:path";
@@ -515,7 +516,7 @@ async function checkAssertion(
515
516
  agentDir: string,
516
517
  model: string,
517
518
  effort: Effort | null,
518
- judgeEnv: () => Record<string, string | undefined> = () => sdkEnv(undefined),
519
+ judgeEnv: () => { env: Record<string, string | undefined>; translator?: TranslatorSpec | null } = () => ({ env: sdkEnv(undefined) }),
519
520
  ): Promise<AssertionResult> {
520
521
  const hay = output.toLowerCase();
521
522
  switch (assertion.type) {
@@ -550,13 +551,13 @@ async function checkAssertion(
550
551
  case "judge": {
551
552
  // The judge runs on the account's own model key, never quietly on the
552
553
  // platform's: a customer's eval is not the operator's bill.
553
- let env: Record<string, string | undefined>;
554
+ let m: ReturnType<typeof judgeEnv>;
554
555
  try {
555
- env = judgeEnv();
556
+ m = judgeEnv();
556
557
  } catch (err) {
557
558
  return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
558
559
  }
559
- const verdict = await judge(assertion.value, output, model, effort, env);
560
+ const verdict = await withModelEnv(m, (env) => judge(assertion.value, output, model, effort, env));
560
561
  const passed = /^\s*PASS\b/i.test(verdict);
561
562
  return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
562
563
  }
@@ -639,13 +640,7 @@ export async function runEval(
639
640
  const judges = testCase.expect.filter((a) => a.type === "judge");
640
641
  for (const a of cheap) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
641
642
  if (assertions.every((r) => r.passed)) {
642
- const judgeEnv = () => {
643
- const m = accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
644
- if (m.format !== "anthropic") {
645
- throw new Error(`this account's provider speaks ${m.format}; a judge needs an Anthropic-format provider`);
646
- }
647
- return m.env;
648
- };
643
+ const judgeEnv = () => accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
649
644
  for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
650
645
  } else {
651
646
  for (const a of judges) {
package/src/model-env.ts CHANGED
@@ -11,6 +11,8 @@ import { readAgentsMd } from "./runner.ts";
11
11
  import { getSecret } from "./secrets.ts";
12
12
  import { platform } from "./platform.ts";
13
13
  import { accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
14
+ import { providerPreset } from "./providers.ts";
15
+ import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./translator.ts";
14
16
 
15
17
  /** Refused for want of a usable key; `status` is what an HTTP layer answers. */
16
18
  export class ModelKeyError extends Error {
@@ -28,14 +30,20 @@ export const NO_MODEL_KEY =
28
30
  /**
29
31
  * `platform` is a function: the caller's own way to build the platform's env
30
32
  * (a step's, or the SDK's host env), called only when this account may use
31
- * it. `format` says what the endpoint speaks — a caller with no translator
32
- * refuses anything but anthropic.
33
+ * it. `format` says what the endpoint speaks; `translator` is set when it is
34
+ * not Anthropic's, and `withModelEnv` puts the step's translator in front of
35
+ * it, so a judge or a draft works on any key a run works on (FOL-16).
33
36
  */
34
37
  export function accountModelEnv(
35
38
  tenant: string,
36
39
  workspace: string,
37
40
  platformEnv: () => Record<string, string | undefined>,
38
- ): { env: Record<string, string | undefined>; supply: "provider" | "platform"; format: string } {
41
+ ): {
42
+ env: Record<string, string | undefined>;
43
+ supply: "provider" | "platform";
44
+ format: string;
45
+ translator: TranslatorSpec | null;
46
+ } {
39
47
  const raw =
40
48
  (readAgentsMd(workspaceDir(tenant, workspace))?.data?.provider as Record<string, unknown> | undefined) ??
41
49
  (readAgentsMd(accountDir(tenant))?.data?.provider as Record<string, unknown> | undefined) ??
@@ -43,7 +51,7 @@ export function accountModelEnv(
43
51
  const spec = parseProvider(raw);
44
52
  if (!spec?.baseUrl) {
45
53
  if (!platform.platformKeyAllowed(tenant)) throw new ModelKeyError(NO_MODEL_KEY);
46
- return { env: platformEnv(), supply: "platform", format: "anthropic" };
54
+ return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
47
55
  }
48
56
  const sub = (text: string) =>
49
57
  text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
@@ -52,15 +60,39 @@ export function accountModelEnv(
52
60
  if (!platform.platformKeyAllowed(tenant) && isClaudeLoginToken(token)) {
53
61
  throw new ModelKeyError("model key: a Claude login token (sk-ant-oat…) cannot be used here — use an API key from your provider", 422);
54
62
  }
63
+ const headers = Object.fromEntries(Object.entries(spec.headers).map(([k, v]) => [k, sub(v)]));
64
+ const preset = providerPreset(spec.name);
55
65
  return {
56
- env: providerEnvFor({
66
+ env: providerEnvFor({ baseUrl: spec.baseUrl, token, auth: spec.auth, models: spec.models, headers }),
67
+ supply: "provider",
68
+ format: spec.format,
69
+ translator: translatorSpecFor({
70
+ format: spec.format,
57
71
  baseUrl: spec.baseUrl,
58
72
  token,
59
- auth: spec.auth,
60
- models: spec.models,
61
- headers: Object.fromEntries(Object.entries(spec.headers).map(([k, v]) => [k, sub(v)])),
73
+ headers,
74
+ params: spec.params,
75
+ name: spec.name,
76
+ maxTokensParam: preset?.maxTokensParam,
77
+ reasoningEffort: preset?.reasoningEffort,
62
78
  }),
63
- supply: "provider",
64
- format: spec.format,
65
79
  };
66
80
  }
81
+
82
+ /**
83
+ * Run `fn` with the model env, through a translator on this host when the
84
+ * provider speaks Chat Completions or Responses — the same one a step uses
85
+ * (runner.ts), started for this call and closed after it.
86
+ */
87
+ export async function withModelEnv<T>(
88
+ m: { env: Record<string, string | undefined>; translator?: TranslatorSpec | null },
89
+ fn: (env: Record<string, string | undefined>) => Promise<T>,
90
+ ): Promise<T> {
91
+ if (!m.translator) return fn(m.env);
92
+ const t = await startTranslator(m.translator);
93
+ try {
94
+ return await fn({ ...m.env, ...t.env });
95
+ } finally {
96
+ await t.close();
97
+ }
98
+ }
@@ -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
- const api = buildApiTools("", input.apis, undefined, { env, missing: [] });
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
- 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;
@@ -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
  }