diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4eebffb8..ccab71ce 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.", - "version": "2.2.0" + "version": "2.2.1" }, "plugins": [ { "name": "relay", "description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.", - "version": "2.2.0", + "version": "2.2.1", "author": { "name": "Libar" }, diff --git a/README.md b/README.md index e6e5a406..b0d61296 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ Claude Code update can break. | `fast` | `true` asks OpenAI for its priority tier after `/relay:setup --fast-tier allowed` | `false` | | `network` | `true`, `false`; `write` and `read-only` only | `false` | | `web-search` | `live`, `cached`, `disabled` | `live` | +| `tool-output-token-limit` | Codex only: `compact`, `balanced`, `deep`, `inherit` or a positive integer of tokens retained per tool result | Codex configuration, else the model's default Relay budget (`deep`: a quarter of its context window) | | `cd` | absolute working directory | the agent's directory | | `add-dir` | absolute extra writable root, repeatable; Codex `write` only | none | | `brief` | absolute path of the brief file; nothing follows `---` | the text after `---` | @@ -250,6 +251,51 @@ model_reasoning_effort = "high" Your Codex sign-in, API key and provider configuration apply as they do in the Codex CLI. +Codex's `tool_output_token_limit` controls how much of each tool result is retained in model +history. Codex's own default keeps 10,000 tokens, which cuts the middle out of a review's diff or a +long log, so Relay gives every Codex job a budget. Each model has three presets, in tokens per tool +result: + +- `deep`: a quarter of the model's context window, to the nearest power of two (65536 for a 272k + window, 131072 for 500k). This is the default. +- `balanced`: half of `deep`. +- `compact`: a quarter of `deep`. + +The window comes from Codex's `model_context_window` setting, else from Codex's model catalog +cache; when neither reports it, 272000 is assumed. Unless `model_context_window` is configured, +models reviewed by hand override the formula in +[`tool-output-budgets.json`](plugins/relay/scripts/lib/backends/codex/tool-output-budgets.json): + +| Model | `compact` | `balanced` | `deep` | Default | +| --- | ---: | ---: | ---: | --- | +| `gpt-6-astra` | 32768 | 65536 | 131072 | `balanced` | +| `gpt-6.1-sol` | 32768 | 65536 | 131072 | `deep` | +| `gpt-6.1-terra` | 32768 | 65536 | 131072 | `deep` | +| `gpt-6-luna` | 16384 | 32768 | 65536 | `deep` | + +The values are proposed engineering judgments, not vendor settings or measured optima. A job that +sets no budget gets the Codex configuration's `tool_output_token_limit` when there is one, else its +model's default, and its state block names the budget and its basis. `/relay:setup` shows the +budget a job on the default model gets and where it comes from. Setup never writes it. + +To choose for one job, add `tool-output-token-limit:` to its header with `compact`, `balanced`, +`deep`, a number of tokens, or `inherit` to send no budget and leave Codex's configuration or default +in charge. The same `--tool-output-token-limit` flag works with `start`, `task`, `review` and +`adversarial-review`. A chosen budget is stored with the job, shown in its state and JSON status, +and reapplied on continue, resume and worker recovery. A preset resolves each time the thread +opens, after Relay reads the Codex configuration, and the state block names its value. Other backends reject this option. + +Codex's code mode, which current models use for every tool call, returns 10,000 tokens of each +`exec` or `wait` result unless the call asks for more, whatever the budget is; no setting changes +that. When a job's effective budget (its own, the Codex configuration's, or Relay's default) is +above 10,000, Relay adds a developer instruction with the budget and the +`// @exec: {"max_output_tokens": N}` pragma, after any developer instructions you configured. + +Larger results use more context and can bring compaction forward. This budget does not increase +the model's context window or generated-answer limit, and cannot restore output already +truncated by a tool. Codex CLI and Relay update independently; a Relay update does not replace the +`codex` executable used by jobs. + ## What the relay depends on in Claude Code The relay reads two things Claude Code does not publish as an interface: the agent transcript and diff --git a/docs/backends.md b/docs/backends.md index 91dbdef8..27995782 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -58,7 +58,7 @@ lib/backends/ | `backendFor(request)` | the backend of a stored request; a request without `backend` is the default's | | `backendWith(capability)` | the one backend that has a capability; it throws unless exactly one has it | | `BACKEND_HEADER_KEYS` | every backend's own header keys, mapped to their request fields | -| `backendHeaderValues(options)` | those keys read from command flags, so `start` and the reviews take `--grok-home` and `--kimi-home` | +| `backendHeaderValues(options)` | those keys read from command flags for `start`, `task` and reviews | `task --backend ` checks the name with `backendNamed` and stores it in the request. The stop gate picks its backend with `backendWith("stopGate")`: it probes that backend, starts its review @@ -115,9 +115,11 @@ Generic code derives the rest: backend but the default. Grok prints `grok-home:` for a home other than `~/.grok`, and Kimi `kimi-home:` for one other than `~/.kimi-code`. - `view.settingLines?(request)` supplies backend settings at the settings position of the launch - block and the state block, after `tier: fast`. Codex uses it for `mcp-off:`. + block and the state block, after `tier: fast`. Codex uses it for `mcp-off:` and an explicit + `tool-output-token-limit:`. - `view.json(request)` supplies the backend's fields for `status --json` and `start --json`, - beside `backend`. Grok adds `grokHome` and Kimi `kimiHome`. A setting line adds no JSON field. + beside `backend`. Grok adds `grokHome`, Kimi `kimiHome`, and Codex an explicit + `toolOutputTokenLimit`. A setting line does not automatically add a JSON field. - `view.completionInferenceLine?(inference, { sessionId, turnId })` formats the current attempt's recorded inference as a `note:` line, or returns null when it does not apply. Generic code passes the inference as backend data and does not read it. Codex checks the inference's thread diff --git a/docs/design.md b/docs/design.md index 817e07ff..553825e5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -491,6 +491,25 @@ binaries and never start a real backend. writes nothing there. `default` takes the configured `model` and `model_reasoning_effort`. Sign-in is the CLI's: ChatGPT, an API key, or none when the configured provider needs no OpenAI sign-in (setup's `not-needed`, from openai/codex-plugin-cc#177). +- **Tool-output budget.** Codex keeps 10,000 tokens of each tool result by default, which truncates + a review's diff, so every Codex job gets a Relay budget. Presets are formulas of the model's + context window: `deep` is a quarter of it to the nearest power of two and the default, + `balanced` half of `deep`, `compact` a quarter. The window is `model_context_window`, else the + model's entry in Codex's `models_cache.json`, else 272000; `model/list` reports neither window + nor budget. `codex/tool-output-budgets.json` holds hand-reviewed values that replace the formula + for named models, with a version, unless `model_context_window` is configured: the reviewed + values assume the catalog's window. A job's `tool-output-token-limit` takes a preset, a number or + `inherit`. A preset resolves at each thread open, after `config/read`, so a configured + `model_context_window` bounds it whether or not the job names its model. The number + is sent as `config.tool_output_token_limit` on thread start and resume, including recovery, and on + a native review's source thread. Without a choice, each thread open asks `config/read`: a + configured budget stays Codex's; otherwise Relay sends the model's default and writes a `note:` + naming the value and its basis. Code mode cuts each `exec` and `wait` result to 10,000 tokens + unless the call asks for more (`max_output_tokens`, `max_tokens`), whatever the budget; no + setting changes that default. So when the effective budget is above 10,000, the thread opens + with `developerInstructions` naming the budget and the pragma, appended to the configured + `developer_instructions`. + See [Codex configuration](../README.md#codex-configuration) for usage and limits. - **Job MCP servers off.** `setup --job-mcp-off name,...` stores a user-level list. A job passes `-c mcp_servers..enabled=false` for each listed name the home's `config.toml` defines and skips any other name with a `note:`, because Codex refuses to start on a setting for a server its diff --git a/package-lock.json b/package-lock.json index 8b098e30..bee90739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@libar-dev/relay-plugin-cc", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@libar-dev/relay-plugin-cc", - "version": "2.2.0", + "version": "2.2.1", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index f689391f..0692994c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@libar-dev/relay-plugin-cc", - "version": "2.2.0", + "version": "2.2.1", "private": true, "type": "module", "description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.", diff --git a/plugins/relay/.claude-plugin/plugin.json b/plugins/relay/.claude-plugin/plugin.json index 95ac01e5..626c4f80 100644 --- a/plugins/relay/.claude-plugin/plugin.json +++ b/plugins/relay/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "relay", - "version": "2.2.0", + "version": "2.2.1", "description": "Run jobs on Codex, Grok Build, ZCode and Kimi Code from Claude Code like Claude agents.", "author": { "name": "Libar" diff --git a/plugins/relay/CHANGELOG.md b/plugins/relay/CHANGELOG.md index 30cc19bd..f7911fd9 100644 --- a/plugins/relay/CHANGELOG.md +++ b/plugins/relay/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 2.2.1 (2026-10-30) + +- Codex jobs and reviews no longer lose the middle of large tool results. Codex keeps 10,000 + tokens of each result by default, which cut review diffs and logs short. Every Codex job now gets + a budget unless it or the Codex configuration sets one: the model's `deep` preset, a quarter of + its context window to the nearest power of two (65536 for 272k, 131072 for 500k). `balanced` is + half of that and `compact` a quarter. Hand-reviewed models override the formula: gpt-6-astra + defaults to `balanced` (65536) with `deep` at 131072. +- Code mode, which current Codex models use for every tool call, returns 10,000 tokens of each + `exec` result unless the call asks for more, whatever the budget. When a job's budget, from the + job, the Codex configuration or Relay's default, is above that, Relay now tells the model the + budget and the `// @exec: {"max_output_tokens": N}` pragma in developer instructions, after any + the user configured. Native reviews print their budget note. +- Codex jobs accept `tool-output-token-limit` in the header, or `--tool-output-token-limit` on + start, task and review commands: a preset, a number of tokens, or `inherit`. The flag wins over + a prompt file's header. The budget is stored + with the job and reapplied on continue, resume and recovery. Other backends reject it. + `/relay:setup` shows the budget a job gets and its source, without changing configuration. + ## 2.2.0 (2026-10-22) From a survey of every T3 Code thread on three machines, about 360 relay jobs from 17 to 22 October, diff --git a/plugins/relay/commands/adversarial-review.md b/plugins/relay/commands/adversarial-review.md index 0c0b99a7..307796c8 100644 --- a/plugins/relay/commands/adversarial-review.md +++ b/plugins/relay/commands/adversarial-review.md @@ -1,6 +1,6 @@ --- description: Run a read-only review that challenges the local diff's approach and design, on Codex, Grok, ZCode or Kimi (pick one with --backend); its findings come back as schema-checked JSON -argument-hint: '[--wait|--background] [--backend codex|grok|zcode|kimi] [--model ] [--effort ] [--grok-home H] [--kimi-home H] [--base ] [--scope auto|working-tree|branch] [focus ...]' +argument-hint: '[--wait|--background] [--backend codex|grok|zcode|kimi] [--model ] [--effort ] [--grok-home H] [--kimi-home H] [--tool-output-token-limit compact|balanced|deep|inherit|N] [--base ] [--scope auto|working-tree|branch] [focus ...]' allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- @@ -35,6 +35,7 @@ Execution mode rules: Backend selection (the flags mirror a job header's keys): - `--backend codex|grok|zcode|kimi` picks the backend; without it, Codex. Nothing stores a default. +- `--tool-output-token-limit compact|balanced|deep|inherit|` sets Codex's per-tool-result history budget for this review. Without it, the Codex configuration's budget applies, else the model's default Relay budget (`deep`, a quarter of its context window, or its reviewed value). Other backends reject it. - `--model ` and `--effort ` are passed as given. Without `--model` and `--effort`, Codex, Grok and Kimi use their configured defaults, and ZCode uses its selected default model at effort `high`, the plugin's default (ZCode's own is `max`), as a job header does. - A Codex review runs in the Codex home (`~/.codex`, or `CODEX_HOME`); `--grok-home ` runs a Grok review in that Grok home, and `--kimi-home ` a Kimi review in that Kimi home. - A combination a job would refuse (`--grok-home` without Grok, `--kimi-home` without Kimi) is refused the same way, before anything runs. diff --git a/plugins/relay/commands/review.md b/plugins/relay/commands/review.md index 9cf9702c..da3501e2 100644 --- a/plugins/relay/commands/review.md +++ b/plugins/relay/commands/review.md @@ -1,6 +1,6 @@ --- description: Run a read-only code review of the local git diff (working tree or branch): Codex's own reviewer on Codex, the plugin's review prompt on Grok, ZCode or Kimi; pick one with --backend -argument-hint: '[--wait|--background] [--backend codex|grok|zcode|kimi] [--model ] [--effort ] [--grok-home H] [--kimi-home H] [--base ] [--scope auto|working-tree|branch]' +argument-hint: '[--wait|--background] [--backend codex|grok|zcode|kimi] [--model ] [--effort ] [--grok-home H] [--kimi-home H] [--tool-output-token-limit compact|balanced|deep|inherit|N] [--base ] [--scope auto|working-tree|branch]' allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- @@ -33,6 +33,7 @@ Execution mode rules: Backend selection (the flags mirror a job header's keys): - `--backend codex|grok|zcode|kimi` picks the backend; without it, Codex. Nothing stores a default. +- `--tool-output-token-limit compact|balanced|deep|inherit|` sets Codex's per-tool-result history budget for this review. Without it, the Codex configuration's budget applies, else the model's default Relay budget (`deep`, a quarter of its context window, or its reviewed value). Other backends reject it. - `--model ` and `--effort ` are passed as given. Without `--model` and `--effort`, Codex, Grok and Kimi use their configured defaults, and ZCode uses its selected default model at effort `high`, the plugin's default (ZCode's own is `max`), as a job header does. - A Codex review runs in the Codex home (`~/.codex`, or `CODEX_HOME`); `--grok-home ` runs a Grok review in that Grok home, and `--kimi-home ` a Kimi review in that Kimi home. - A combination a job would refuse (`--grok-home` without Grok, `--kimi-home` without Kimi) is refused the same way, before anything runs. diff --git a/plugins/relay/commands/setup.md b/plugins/relay/commands/setup.md index 5f1a2341..d897a588 100644 --- a/plugins/relay/commands/setup.md +++ b/plugins/relay/commands/setup.md @@ -36,6 +36,12 @@ or `not-installed`, print its `detail` and the command it names, and go on; only install offer below. Print every entry of a block's `warnings`; for Grok, a warning that the binary is a script names the real binary and `GROK_BINARY`. +When a Codex block has `toolOutputTokenLimit`, show its `value` and `source` below the table. +For a null value, show the source text alone. This is the budget a job on the default model +gets: the Codex configuration's, else the model's default Relay budget (`deep`, a quarter of its +context window, or its reviewed value). Setup only reads it. A job's `tool-output-token-limit` +header (`compact`, `balanced`, `deep`, `inherit` or a number) overrides it for that job. + `--job-mcp-off ` stores a user-level list. A Codex job passes `mcp_servers..enabled=false` for each name the Codex home's `config.toml` defines as `[mcp_servers.]`; it skips an undefined name with a note and never passes it. Use diff --git a/plugins/relay/scripts/cli/start.mjs b/plugins/relay/scripts/cli/start.mjs index 2519ed50..d1d73d62 100644 --- a/plugins/relay/scripts/cli/start.mjs +++ b/plugins/relay/scripts/cli/start.mjs @@ -27,7 +27,10 @@ function readLaunchFromPrompt(text) { function readLaunchFromOptions(options) { if (options["prompt-file"]) { - return readLaunchFromPrompt(readInputFile(options["prompt-file"])); + // A backend flag given beside a prompt file wins over the same key in its header. + const launch = readLaunchFromPrompt(readInputFile(options["prompt-file"])); + const flags = Object.entries(backendHeaderValues(options)).filter(([, value]) => value != null); + return { ...launch, values: { ...launch.values, ...Object.fromEntries(flags) } }; } if (!options["brief-file"]) { throw new UsageError("start needs --prompt-file , or header options and --brief-file ."); diff --git a/plugins/relay/scripts/cli/task.mjs b/plugins/relay/scripts/cli/task.mjs index aa593a22..a0391665 100644 --- a/plugins/relay/scripts/cli/task.mjs +++ b/plugins/relay/scripts/cli/task.mjs @@ -1,4 +1,4 @@ -import { backendNamed, backendWith } from "../lib/backends/index.mjs"; +import { backendNamed, backendWith, BACKEND_HEADER_KEYS, backendHeaderValues } from "../lib/backends/index.mjs"; import { parseCommandInput, resolveCommandCwd, createTaskJob, outputResult } from "./shared.mjs"; import { default as fs } from "node:fs"; import { default as path } from "node:path"; @@ -18,7 +18,7 @@ const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude t export async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["backend", "model", "effort", "mode", "cwd", "cd", "prompt-file", "web-search", "network"], + valueOptions: ["backend", "model", "effort", "mode", "cwd", "cd", "prompt-file", "web-search", "network", ...Object.keys(BACKEND_HEADER_KEYS)], booleanOptions: ["json", "write", "background"], aliasMap: { m: "model" } }); @@ -35,6 +35,7 @@ export async function handleTask(argv) { const request = buildLaunchRequest( { backend: options.backend == null ? undefined : backendNamed(options.backend).name, + ...backendHeaderValues(options), model: options.model ?? "default", effort: options.effort ?? "default", mode: options.write ? "write" : options.mode, diff --git a/plugins/relay/scripts/companion.mjs b/plugins/relay/scripts/companion.mjs index b96f97d7..7ba06329 100644 --- a/plugins/relay/scripts/companion.mjs +++ b/plugins/relay/scripts/companion.mjs @@ -27,6 +27,7 @@ function printUsage() { " companion.mjs start --model --effort [--mode yolo|write|read-only] [--network true|false]", ` [--fast] [--backend ${names}] ${homeFlags} [--web-search live|cached|disabled]`, " [--cd ] [--add-dir ]... --brief-file ", + " Codex start/task/review/adversarial-review: [--tool-output-token-limit compact|balanced|deep|inherit|]", " companion.mjs wait [--timeout-seconds N] [--state-only] [--exit-zero-while-running] [--json]", " companion.mjs status [] [--all] [--json]", " companion.mjs result [--state-only] [--json]", diff --git a/plugins/relay/scripts/lib/backends/codex/connection.mjs b/plugins/relay/scripts/lib/backends/codex/connection.mjs index 6c99873b..bc01ee4f 100644 --- a/plugins/relay/scripts/lib/backends/codex/connection.mjs +++ b/plugins/relay/scripts/lib/backends/codex/connection.mjs @@ -1,7 +1,7 @@ /** @typedef {import("../types").Connection} Connection */ import { CodexAppServerClient } from "./client.mjs"; import { clientIdsInThread, openThread, runTurnOnThread } from "./turn.mjs"; -import { approvalPolicyFor, sandboxFor, sandboxPolicyFor, threadConfigFor } from "./request.mjs"; +import { approvalPolicyFor, sandboxFor, sandboxPolicyFor, threadConfigFor, toolOutputTokenLimitFor } from "./request.mjs"; // The Codex app-server's arguments for a request: the job MCP servers it turns off. export function codexServerArgs(request) { @@ -12,9 +12,10 @@ export function codexServerArgs(request) { /** @implements {Connection} */ class CodexConnection { /** @param {import("../types").LaunchRequest} request */ - constructor(client, request) { + constructor(client, request, env) { this.client = client; this.request = request; + this.env = env; this.sessionId = null; this.deliveredIds = new Set(); this.steerTracker = { turnSteerHandler: null }; @@ -25,15 +26,18 @@ class CodexConnection { /** @param {import("../types").OpenOptions} options */ async open({ sessionId, sessionName, ephemeral }) { const request = this.request; + const config = threadConfigFor(request); + const budget = await toolOutputTokenLimitFor(this.client, request, this.env); + if (budget.limit != null) config.tool_output_token_limit = budget.limit; const opened = await openThread(this.client, request.cwd, { threadId: sessionId, threadName: sessionName, ephemeral, model: request.model, serviceTier: request.fast ? "priority" : null, sandbox: sandboxFor(request), approvalPolicy: approvalPolicyFor(), - config: threadConfigFor(request) + config, developerInstructions: budget.developerInstructions }); this.sessionId = opened.threadId; this.deliveredIds = clientIdsInThread(opened.response); - return { sessionId: opened.threadId, resumed: opened.resumed, serviceTier: opened.response.serviceTier ?? null }; + return { sessionId: opened.threadId, resumed: opened.resumed, serviceTier: opened.response.serviceTier ?? null, notes: budget.note ? [budget.note] : [] }; } /** @param {Pick} item */ @@ -87,7 +91,7 @@ export async function connect(request, hooks) { env: hooks.env, onSpawn: hooks.onSpawn, onStderr: hooks.onStderr, onEnd: hooks.onEnd, notificationTap: hooks.tap, ...codexServerArgs(request) }); - return new CodexConnection(client, request); + return new CodexConnection(client, request, hooks.env); } // Request limits retry; usage and quota limits do not. The protocol kind is authoritative diff --git a/plugins/relay/scripts/lib/backends/codex/index.mjs b/plugins/relay/scripts/lib/backends/codex/index.mjs index 69010b02..f933a8e7 100644 --- a/plugins/relay/scripts/lib/backends/codex/index.mjs +++ b/plugins/relay/scripts/lib/backends/codex/index.mjs @@ -55,10 +55,18 @@ export const codexBackend = { view: { settingLines(request) { const mcpOff = /** @type {string[] | undefined} */ (request.mcpOff); - return mcpOff?.length > 0 ? [`mcp-off: ${mcpOff.join(", ")}`] : []; + return [ + ...(mcpOff?.length > 0 ? [`mcp-off: ${mcpOff.join(", ")}`] : []), + ...(request.toolOutputPreset || request.toolOutputTokenLimit != null ? [`tool-output-token-limit: ${[request.toolOutputPreset, request.toolOutputTokenLimit].filter(part => part != null).join(" ")}`] : []) + ]; }, lines() { return []; }, - json() { return {}; }, + json(request) { + return { + ...(request.toolOutputTokenLimit == null ? {} : { toolOutputTokenLimit: request.toolOutputTokenLimit }), + ...(request.toolOutputPreset ? { toolOutputPreset: request.toolOutputPreset } : {}) + }; + }, completionInferenceLine(inference, { sessionId, turnId }) { if (inference.threadId !== sessionId || inference.turnId !== turnId) return null; return `note: turn completion inferred; Codex sent no turn/completed${inference.reason ? `; thread/read: ${inference.reason}` : ""}`; diff --git a/plugins/relay/scripts/lib/backends/codex/protocol.d.ts b/plugins/relay/scripts/lib/backends/codex/protocol.d.ts index 292843dd..007756ff 100644 --- a/plugins/relay/scripts/lib/backends/codex/protocol.d.ts +++ b/plugins/relay/scripts/lib/backends/codex/protocol.d.ts @@ -24,6 +24,7 @@ export interface ThreadStartParams { approvalPolicy?: AskForApproval | null; sandbox?: SandboxMode | null; config?: { [key: string]: JsonValue } | null; + developerInstructions?: string | null; serviceName?: string | null; ephemeral?: boolean | null; } @@ -35,6 +36,7 @@ export interface ThreadResumeParams { approvalPolicy?: AskForApproval | null; sandbox?: SandboxMode | null; config?: { [key: string]: JsonValue } | null; + developerInstructions?: string | null; } export interface Thread { id: string; diff --git a/plugins/relay/scripts/lib/backends/codex/request.mjs b/plugins/relay/scripts/lib/backends/codex/request.mjs index 73958d20..29d60ee4 100644 --- a/plugins/relay/scripts/lib/backends/codex/request.mjs +++ b/plugins/relay/scripts/lib/backends/codex/request.mjs @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; +import { UsageError } from "../../util/errors.mjs"; const MODES = new Map([ ["yolo", "danger-full-access"], @@ -26,9 +27,95 @@ export function codexDefaultHome(env = process.env) { return path.resolve(env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex")); } +// Codex keeps 10,000 tokens of each tool result by default, which cuts the middle out of a +// review's diff or a log, so every Codex job gets a Relay budget. `tool-output-budgets.json` +// holds the reviewed models. Every other model gets the formula: `deep` is a quarter of its +// context window to the nearest power of two (65536 for 272k, 131072 for 500k), `balanced` half +// of that and `compact` a quarter, and `deep` is its default. The window comes from +// `model_context_window`, else from Codex's model catalog cache, else 272000 is assumed. +export const TOOL_OUTPUT_BUDGETS = JSON.parse(fs.readFileSync(new URL("./tool-output-budgets.json", import.meta.url), "utf8")); +const TOOL_OUTPUT_PRESETS = ["compact", "balanced", "deep"]; +const ASSUMED_CONTEXT_WINDOW = 272000; + +function contextWindowFor(model, config, codexHome) { + const configured = config?.model_context_window; + if (Number.isSafeInteger(configured) && configured > 0) return configured; + try { + const catalog = JSON.parse(fs.readFileSync(path.join(codexHome, "models_cache.json"), "utf8")); + const window = catalog?.models?.find((entry) => entry?.slug === model)?.context_window; + return Number.isSafeInteger(window) && window > 0 ? window : null; + } catch { + return null; + } +} + +// A model's presets, its default preset and where they come from. A configured +// `model_context_window` wins over the reviewed values, which assume the catalog's window. +/** @param {string | null | undefined} model @param {{ config?: Record, codexHome: string }} options */ +export function budgetsFor(model, { config = {}, codexHome }) { + const configured = Number.isSafeInteger(config?.model_context_window) && config.model_context_window > 0; + if (!configured && model && Object.hasOwn(TOOL_OUTPUT_BUDGETS.models, model)) { + return { ...TOOL_OUTPUT_BUDGETS.models[model], basis: `reviewed ${TOOL_OUTPUT_BUDGETS.version}` }; + } + const window = contextWindowFor(model, config, codexHome); + const deep = 2 ** Math.round(Math.log2((window ?? ASSUMED_CONTEXT_WINDOW) / 4)); + return { + compact: deep / 4, balanced: deep / 2, deep, default: "deep", + basis: window ? `context ${window} / 4` : `context window not reported, ${ASSUMED_CONTEXT_WINDOW} assumed` + }; +} + +// A header value: a positive token count, a preset, or `inherit` (no override). A preset +// resolves when the thread opens, after `config/read`, so a configured `model_context_window` +// bounds it whether or not the job names its model. +function toolOutputFields(value) { + if (value == null) return {}; + const text = String(value).trim().toLowerCase(); + if (text === "inherit" || TOOL_OUTPUT_PRESETS.includes(text)) return { toolOutputPreset: text }; + const limit = Number(text); + if (!/^\d+$/.test(text) || !Number.isSafeInteger(limit) || limit <= 0) { + throw new UsageError(`tool-output-token-limit must be compact, balanced, deep, inherit or a positive safe integer (tokens), not "${value}".`); + } + return { toolOutputTokenLimit: limit }; +} + +// Code mode returns 10,000 tokens of each `exec` or `wait` result unless the call asks for more, +// whatever the history budget is, and no setting changes that default. A thread whose budget is +// larger tells the model how to ask, after the developer instructions the user configured. +const CODE_MODE_DEFAULT_TOKENS = 10000; + +function developerInstructionsFor(limit, config) { + if (!(limit > CODE_MODE_DEFAULT_TOKENS)) return null; + const relay = `Tool output: this job keeps up to ${limit} tokens of each tool result. In code mode, \`exec\` and \`wait\` return only ${CODE_MODE_DEFAULT_TOKENS} tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with \`// @exec: {"max_output_tokens": ${limit}}\`, or pass \`max_tokens: ${limit}\` to \`wait\`, rather than working from a truncated result.`; + return [typeof config.developer_instructions === "string" ? config.developer_instructions : null, relay].filter(Boolean).join("\n\n"); +} + +// The budget a thread opens with, a note when Relay chose it, and the developer instructions that +// carry it into code mode: the job's number; none for `inherit` or when the Codex configuration +// sets one; else the job's preset or the model's default. A failed `config/read` counts as no +// configured budget. +export async function toolOutputTokenLimitFor(client, request, env = process.env) { + let config = {}; + try { + config = (await client.request("config/read", { includeLayers: false, cwd: request.cwd }))?.config ?? {}; + } catch { + config = {}; + } + const configured = Number.isSafeInteger(config.tool_output_token_limit) ? config.tool_output_token_limit : null; + const result = (limit, note) => ({ limit, note, developerInstructions: developerInstructionsFor(limit ?? configured, config) }); + if (request.toolOutputTokenLimit != null) return result(request.toolOutputTokenLimit, null); + if (request.toolOutputPreset === "inherit") return result(null, null); + if (!request.toolOutputPreset && config.tool_output_token_limit != null) return result(null, null); + const model = request.model && request.model !== "default" ? request.model : config.model; + const budgets = budgetsFor(model, { config, codexHome: codexDefaultHome(env) }); + const preset = request.toolOutputPreset ?? budgets.default; + return result(budgets[preset], `tool-output-token-limit: ${budgets[preset]}, Relay's ${preset} budget for ${model ?? "the default model"} (${budgets.basis}).`); +} + // thread/start and thread/resume `config` overrides for a request. export function threadConfigFor(request) { const config = { web_search: request.webSearch }; + if (request.toolOutputTokenLimit != null) config.tool_output_token_limit = request.toolOutputTokenLimit; if (request.mode === "write") { config.sandbox_workspace_write = { writable_roots: request.writableRoots, @@ -65,15 +152,16 @@ export function approvalPolicyFor() { return "never"; } /** @type {import("../types").RequestRules} */ export const requestRules = { - headerKeys: {}, + headerKeys: { "tool-output-token-limit": "toolOutputTokenLimit" }, defaultSource: "let the Codex config decide", defaultEffort: null, refusals: {}, - complete(request, _values, { env, userConfig }) { + complete(request, values, { env, userConfig }) { + const budget = toolOutputFields(values.toolOutputTokenLimit); const defined = mcpServersInConfig(codexDefaultHome(env)); for (const name of userConfig.mcpOff.filter(name => !defined.has(name))) { request.notes.push(`job-mcp-off skips ${name}: it is not in this home's config.toml.`); } - return { mcpOff: userConfig.mcpOff.filter(name => defined.has(name)) }; + return { mcpOff: userConfig.mcpOff.filter(name => defined.has(name)), ...budget }; } }; diff --git a/plugins/relay/scripts/lib/backends/codex/review.mjs b/plugins/relay/scripts/lib/backends/codex/review.mjs index 22c95901..fb54a242 100644 --- a/plugins/relay/scripts/lib/backends/codex/review.mjs +++ b/plugins/relay/scripts/lib/backends/codex/review.mjs @@ -1,5 +1,6 @@ import { getCodexAvailability } from "./account.mjs"; import { codexServerArgs } from "./connection.mjs"; +import { toolOutputTokenLimitFor } from "./request.mjs"; import { withAppServer, startThread, captureTurn, buildResultStatus, cleanCodexStderr, connectOptions } from "./turn.mjs"; @@ -10,8 +11,12 @@ async function runAppServerReview(cwd, options = {}) { } return withAppServer(cwd, async (client) => { + const budget = await toolOutputTokenLimitFor(client, options.request); + if (budget.note) options.request.notes.push(budget.note); const thread = await startThread(client, cwd, { model: options.model, + config: budget.limit == null ? undefined : { tool_output_token_limit: budget.limit }, + developerInstructions: budget.developerInstructions, sandbox: "read-only", ephemeral: true }); @@ -62,7 +67,8 @@ export const reviewSupport = { ensureAvailable(request.cwd); const result = await runAppServerReview(request.cwd, { target: target.mode === "working-tree" ? { type: "uncommittedChanges" } : { type: "baseBranch", branch: target.baseRef }, - model: request.model, notificationTap: hooks.tap, ...codexServerArgs(request) + model: request.model, request, + notificationTap: hooks.tap, ...codexServerArgs(request) }); return { status: result.status, sessionId: result.threadId, sourceSessionId: result.sourceThreadId, diff --git a/plugins/relay/scripts/lib/backends/codex/setup.mjs b/plugins/relay/scripts/lib/backends/codex/setup.mjs index 63ac0e33..228cc3a0 100644 --- a/plugins/relay/scripts/lib/backends/codex/setup.mjs +++ b/plugins/relay/scripts/lib/backends/codex/setup.mjs @@ -1,11 +1,11 @@ import { SpawnedServerClient } from "../transport/stdio-rpc.mjs"; import { codexBackend } from "./index.mjs"; -import { codexDefaultHome } from "./request.mjs"; +import { budgetsFor, codexDefaultHome } from "./request.mjs"; import { emptyBlock, notInstalled, isScript, firstLine, lastLine, seconds, closeWithin, effortName, executablesOnPath, versionOf } from "../setup.mjs"; // One app-server in the Codex home: `account/read`, `config/read`, `model/list`. No thread is opened. async function askCodexServer(cwd, home, env, limitMs) { - const answer = { account: null, config: null, models: null, modelsError: null, error: null, warnings: [] }; + const answer = { account: null, config: null, toolOutputOrigin: null, models: null, modelsError: null, error: null, warnings: [] }; let stderr = ""; // `account/read`, `config/read` and `model/list` are outside the typed method map. const client = /** @type {any} */ (new SpawnedServerClient(cwd, { @@ -23,8 +23,10 @@ async function askCodexServer(cwd, home, env, limitMs) { await client.initialize(); const account = await client.request("account/read", { refreshToken: false }); answer.account = account && typeof account === "object" ? account : null; - const config = (await client.request("config/read", { includeLayers: false, cwd }))?.config; + const result = await client.request("config/read", { includeLayers: false, cwd }); + const config = result?.config; answer.config = config && typeof config === "object" ? config : {}; + answer.toolOutputOrigin = result?.origins?.tool_output_token_limit?.name ?? null; try { const models = []; let cursor = null; @@ -102,6 +104,16 @@ async function checkCodexHome(cwd, home, env, shared, limits) { } const signIn = codexSignIn(answer.account, answer.config); block.signIn = signIn; + const budget = answer.config.tool_output_token_limit; + const origin = answer.toolOutputOrigin; + const location = origin?.file ?? origin?.dotCodexFolder; + const model = answer.config.model; + const budgets = budgetsFor(model, { config: answer.config, codexHome: home.home }); + block.toolOutputTokenLimit = budget == null + ? { value: budgets[budgets.default], source: `Relay's ${budgets.default} budget for ${model ?? "the default model"} (${budgets.basis})` } + : Number.isSafeInteger(budget) && budget >= 0 + ? { value: budget, source: origin?.type ? `${origin.type} configuration${location ? ` (${location})` : ""}` : "configuration (source not reported)" } + : { value: null, source: "unknown (unrecognized configured budget)" }; const catalog = (answer.models ?? []).filter(isCatalogModel).filter(model => !model.hidden); block.models = catalog.map(model => ({ id: model.id ?? model.model, @@ -130,6 +142,10 @@ export const setupProbe = { return { binary, version: versionOf(binary, "codex", cwd, env, limits) }; }, check(home, shared, { cwd, env, limits }) { return checkCodexHome(cwd, home, env, shared, limits); }, + settingLines(block) { + const budget = block.toolOutputTokenLimit; + return budget ? [`tool-output-token-limit: ${budget.value == null ? budget.source : `${budget.value} (${budget.source})`}`] : []; + }, signInText({ signIn: { state, method, detail } }) { if (state !== "signed-in") return null; if (method === "chatgpt") return `signed in (ChatGPT${detail?.includes(" for ") ? `, ${detail.split(" for ").pop()}` : ""})`; diff --git a/plugins/relay/scripts/lib/backends/codex/tool-output-budgets.json b/plugins/relay/scripts/lib/backends/codex/tool-output-budgets.json new file mode 100644 index 00000000..8ffc45d0 --- /dev/null +++ b/plugins/relay/scripts/lib/backends/codex/tool-output-budgets.json @@ -0,0 +1,36 @@ +{ + "version": "2026-10-30", + "unit": "tokens retained in model history per tool result (Codex tool_output_token_limit)", + "basis": "Codex's own default keeps 10000 tokens of each result. These rows are reviewed values for named models; every other model gets the formula in request.mjs (deep = context window / 4 to the nearest power of two, balanced = deep / 2, compact = deep / 4, default deep). Proposed engineering values, not vendor settings or measured optima; context is the window Codex's model catalog advertised on the review date.", + "status": "proposed", + "models": { + "gpt-6-astra": { + "context": 272000, + "compact": 32768, + "balanced": 65536, + "deep": 131072, + "default": "balanced" + }, + "gpt-6.1-sol": { + "context": 500000, + "compact": 32768, + "balanced": 65536, + "deep": 131072, + "default": "deep" + }, + "gpt-6.1-terra": { + "context": 500000, + "compact": 32768, + "balanced": 65536, + "deep": 131072, + "default": "deep" + }, + "gpt-6-luna": { + "context": 272000, + "compact": 16384, + "balanced": 32768, + "deep": 65536, + "default": "deep" + } + } +} diff --git a/plugins/relay/scripts/lib/backends/codex/turn.mjs b/plugins/relay/scripts/lib/backends/codex/turn.mjs index f6e3426c..ca511107 100644 --- a/plugins/relay/scripts/lib/backends/codex/turn.mjs +++ b/plugins/relay/scripts/lib/backends/codex/turn.mjs @@ -2,7 +2,7 @@ * @typedef {import("./protocol").AppServerNotification} AppServerNotification * @typedef {import("./protocol").ThreadResumeParams} ThreadResumeParams * @typedef {import("./protocol").ThreadStartParams} ThreadStartParams - * @typedef {Pick & { threadName?: string | null }} ThreadOpenOptions + * @typedef {Pick & { threadName?: string | null }} ThreadOpenOptions * @typedef {import("./protocol").Turn} Turn * @typedef {import("./protocol").UserInput} UserInput * @typedef {{ @@ -72,6 +72,7 @@ function buildThreadParams(cwd, options = {}) { approvalPolicy: options.approvalPolicy ?? "never", sandbox: options.sandbox ?? "read-only", ...(options.config ? { config: options.config } : {}), + ...(options.developerInstructions ? { developerInstructions: options.developerInstructions } : {}), serviceName: SERVICE_NAME, ephemeral: options.ephemeral ?? true }; @@ -87,7 +88,8 @@ function buildResumeParams(threadId, cwd, options = {}) { ...(options.serviceTier ? { serviceTier: options.serviceTier } : {}), approvalPolicy: options.approvalPolicy ?? "never", sandbox: options.sandbox ?? "read-only", - ...(options.config ? { config: options.config } : {}) + ...(options.config ? { config: options.config } : {}), + ...(options.developerInstructions ? { developerInstructions: options.developerInstructions } : {}) }; } diff --git a/plugins/relay/scripts/lib/backends/types.d.ts b/plugins/relay/scripts/lib/backends/types.d.ts index 04752b41..e4f8204d 100644 --- a/plugins/relay/scripts/lib/backends/types.d.ts +++ b/plugins/relay/scripts/lib/backends/types.d.ts @@ -310,6 +310,8 @@ export interface SetupProbe { check(home: SetupHome, shared: unknown, context: SetupContext): Promise; /** The sign-in line, where the generic wording does not fit; null keeps the generic one. */ signInText?(block: SetupBlock): string | null; + /** Additional configuration facts reported by this backend. */ + settingLines?(block: SetupBlock): string[]; /** What to do next for this block; empty when nothing is needed. */ nextSteps(block: SetupBlock, context: SetupContext): string[]; /** The page offers this backend's optional stop gate when it is off. */ @@ -331,6 +333,7 @@ export interface SetupHome { } export interface SetupBlock extends SetupHome { + toolOutputTokenLimit?: { value: number | null, source: string }; binary: string | null; binaryIsScript: boolean; realBinary: string | null; diff --git a/plugins/relay/scripts/lib/setup/page.mjs b/plugins/relay/scripts/lib/setup/page.mjs index bf73a87f..51d6b47d 100644 --- a/plugins/relay/scripts/lib/setup/page.mjs +++ b/plugins/relay/scripts/lib/setup/page.mjs @@ -141,6 +141,7 @@ function renderBlock(block, env) { const effort = block.effort ? `${block.effort}${block.effortNote ? ` (${block.effortNote})` : ""}` : (block.effortNote ?? "unknown"); const model = block.model ? `${block.model}${block.modelNote ? ` (${block.modelNote})` : ""}` : null; lines.push(model ? `model: ${model} effort: ${effort}` : `model: unknown (${block.modelsReason ?? "not reported"})`); + lines.push(...(backendFor(block).setup.settingLines?.(block) ?? [])); if (block.signIn.state !== "signed-out" && block.signIn.state !== "unknown") { const names = block.models.map(model => model.id).join(", "); lines.push(`models: ${names || "none listed"}${block.modelsReason ? ` (${block.modelsReason})` : ""}`); diff --git a/plugins/relay/skills/jobs/SKILL.md b/plugins/relay/skills/jobs/SKILL.md index cc436d96..8acbdf79 100644 --- a/plugins/relay/skills/jobs/SKILL.md +++ b/plugins/relay/skills/jobs/SKILL.md @@ -47,11 +47,13 @@ cd: /abs/path/to/repo | `mode` | no | `yolo` (no sandbox, no approvals), `write` (`workspace-write`), `read-only` | `yolo` | | `network` | no | `true`, `false`; `write` and `read-only` only | `false` | | `web-search` | no | `live`, `cached`, `disabled` | `live` | +| `tool-output-token-limit` | no | Codex only: `compact`, `balanced`, `deep`, `inherit` or a positive integer of tokens retained per tool result; stored and reapplied on continue, resume and recovery | Codex configuration, else the model's default Relay budget (`deep`: a quarter of its context window) | | `cd` | no | absolute directory the job works in | the agent's directory | | `add-dir` | no | absolute extra writable root, repeatable; `write` only | none | | `brief` | no | absolute path of the brief file; nothing follows the dashes | none | - A model named like another backend's (`glm-*`, `grok-*`, `kimi-code/*`) with no `backend:` is refused before launch, since the job would run on Codex. `backend: codex` sends it to Codex as written. +- Omit `tool-output-token-limit` unless the user or workflow names a budget: every model already gets its default, `deep` (a quarter of its context window: 65536 for 272k, 131072 for 500k; Astra's reviewed default is `balanced`, 65536). Choose `deep` on Astra for a comprehensive review, `compact` for narrow edits, or a number for one result larger than the preset. The budget cannot recover output a tool truncated before returning, and is separate from context size and answer length. - Ask a `read-only` job for its report as its final answer, not in a file it writes. Codex and Grok hold `read-only` with a sandbox: their commands run and cannot write. Kimi and ZCode have no sandbox: `read-only` takes their shell and write tools away, and what they start themselves (hooks, MCP servers, Kimi's own operations) can still write. See Kimi jobs and GLM jobs. - In Codex `mode: write`, a command that keeps its own state under `~` (a CLI's sign-in file, say) cannot write it there. Name that directory with `add-dir:`. Grok's `write` takes no `add-dir:`; such a job needs `mode: yolo`. - `yolo` is the default, so existing headers keep running as they did. `mode: write` with `network: true` covers web search, installs, commits and child processes on Codex and Grok, but not `ps` or writes outside the working directory. `yolo` ignores `network` and `add-dir` and the state block says so. The brief is then the only fence: name where the job may write. diff --git a/tests/fakes/codex.mjs b/tests/fakes/codex.mjs index b626997e..99fd2fb6 100644 --- a/tests/fakes/codex.mjs +++ b/tests/fakes/codex.mjs @@ -501,9 +501,14 @@ rl.on("line", (line) => { break; case "config/read": + record("configReads", message.params); if (BEHAVIOR === "config-read-fails") { throw new Error("config/read failed for cwd"); } + if (process.env.FAKE_CODEX_CONFIG_READ) { + send({ id: message.id, result: JSON.parse(process.env.FAKE_CODEX_CONFIG_READ) }); + break; + } if (BEHAVIOR === "home-probe") { const homeValue = key => new RegExp(key + '\\\\s*=\\\\s*"([^"]+)"').exec(homeConfig)?.[1] ?? null; send({ id: message.id, result: { config: { model: homeValue("model"), model_provider: homeValue("model_provider"), model_reasoning_effort: homeValue("model_reasoning_effort") }, origins: {} } }); diff --git a/tests/fixtures/codex-golden.json b/tests/fixtures/codex-golden.json index d4bc231a..fcdb62a9 100644 --- a/tests/fixtures/codex-golden.json +++ b/tests/fixtures/codex-golden.json @@ -15,10 +15,12 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" + "web_search": "live", + "tool_output_token_limit": 65536 }, "serviceName": "claude_code_codex_plugin", - "ephemeral": false + "ephemeral": false, + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." } }, { @@ -66,8 +68,10 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" - } + "web_search": "live", + "tool_output_token_limit": 65536 + }, + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." } }, { @@ -100,8 +104,10 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" - } + "web_search": "live", + "tool_output_token_limit": 65536 + }, + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." } }, { @@ -129,7 +135,7 @@ "snapshots": [ { "phase": "start-and-steer", - "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 1\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\n", + "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 1\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\nnote: tool-output-token-limit: 65536, Relay's deep budget for gpt-test (context window not reported, 272000 assumed).\n", "status": { "jobsHome": "/jobs", "repoSlug": "", @@ -198,7 +204,7 @@ }, { "phase": "continue", - "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 2\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\n", + "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 2\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\nnote: tool-output-token-limit: 65536, Relay's deep budget for gpt-test (context window not reported, 272000 assumed).\n", "status": { "jobsHome": "/jobs", "repoSlug": "", @@ -292,7 +298,7 @@ }, { "phase": "resume", - "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 3\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\n", + "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 3\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\nnote: tool-output-token-limit: 65536, Relay's deep budget for gpt-test (context window not reported, 272000 assumed).\n", "status": { "jobsHome": "/jobs", "repoSlug": "", @@ -427,10 +433,12 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" + "web_search": "live", + "tool_output_token_limit": 65536 }, "serviceName": "claude_code_codex_plugin", - "ephemeral": false + "ephemeral": false, + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." } }, { @@ -478,8 +486,10 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" - } + "web_search": "live", + "tool_output_token_limit": 65536 + }, + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." } }, { @@ -512,8 +522,10 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" - } + "web_search": "live", + "tool_output_token_limit": 65536 + }, + "developerInstructions": "Tool output: this job keeps up to 65536 tokens of each tool result. In code mode, `exec` and `wait` return only 10000 tokens of output by default and cut the middle. When a command can print more (a diff, a log, a whole file), start the exec input with `// @exec: {\"max_output_tokens\": 65536}`, or pass `max_tokens: 65536` to `wait`, rather than working from a truncated result." } }, { @@ -541,7 +553,7 @@ "snapshots": [ { "phase": "start-and-steer", - "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 1\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\n", + "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 1\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\nnote: tool-output-token-limit: 65536, Relay's deep budget for gpt-test (context window not reported, 272000 assumed).\n", "status": { "jobsHome": "/jobs", "repoSlug": "", @@ -610,7 +622,7 @@ }, { "phase": "continue", - "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 2\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\n", + "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 2\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\nnote: tool-output-token-limit: 65536, Relay's deep budget for gpt-test (context window not reported, 272000 assumed).\n", "status": { "jobsHome": "/jobs", "repoSlug": "", @@ -704,7 +716,7 @@ }, { "phase": "resume", - "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 3\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\n", + "block": "state: completed\njob: \nsession: thr_1\nmodel: gpt-test\neffort: low\nduration: \nattempts: 3\nreport: /jobs///report.md\ndir: /jobs//\nmode: yolo\nweb-search: live\ncwd: /work\nnote: tool-output-token-limit: 65536, Relay's deep budget for gpt-test (context window not reported, 272000 assumed).\n", "status": { "jobsHome": "/jobs", "repoSlug": "", diff --git a/tests/jobs.test.mjs b/tests/jobs.test.mjs index 473f402e..f3d6c05c 100644 --- a/tests/jobs.test.mjs +++ b/tests/jobs.test.mjs @@ -236,7 +236,7 @@ test("the default header starts a yolo session: danger-full-access, never, live const state = ctx.fakeState(); assert.equal(state.threadStarts[0].sandbox, "danger-full-access"); assert.equal(state.threadStarts[0].approvalPolicy, "never"); - assert.deepEqual(state.threadStarts[0].config, { web_search: "live" }); + assert.deepEqual(state.threadStarts[0].config, { web_search: "live", tool_output_token_limit: 65536 }); assert.equal(state.threadStarts[0].ephemeral, false); assert.deepEqual(state.turnStarts[0].sandboxPolicy, { type: "dangerFullAccess" }); assert.match(waited.stdout, /^state: completed$/m); @@ -250,7 +250,7 @@ test("a read-only header still starts a read-only thread", async () => { assert.equal(waitJob(ctx, jobId).status, 0); const state = ctx.fakeState(); assert.equal(state.threadStarts[0].sandbox, "read-only"); - assert.deepEqual(state.threadStarts[0].config, { web_search: "disabled" }); + assert.deepEqual(state.threadStarts[0].config, { web_search: "disabled", tool_output_token_limit: 65536 }); assert.deepEqual(state.turnStarts[0].sandboxPolicy, { type: "readOnly", networkAccess: false }); }); diff --git a/tests/reviews-backends.test.mjs b/tests/reviews-backends.test.mjs index 6ffa486f..322dc708 100644 --- a/tests/reviews-backends.test.mjs +++ b/tests/reviews-backends.test.mjs @@ -69,6 +69,10 @@ function kimi(t, options = {}, files, untracked) { // The fake each review test reads its turns from. const turnsOf = (ctx, backend) => ({ zcode: () => ctx.zcode().turns, grok: () => ctx.grok().turns, kimi: () => ctx.kimi().turns })[backend](); +// A Codex review says first which tool-output budget Relay chose. +const NO_BUDGET = model => `tool-output-token-limit: 65536, Relay's deep budget for ${model} (context window not reported, 272000 assumed).`; +const lead = backend => backend === "codex" ? `note: ${NO_BUDGET("the default model")}\n\n` : ""; + function codex(t, behavior = "review-ok") { const ctx = setup(t, { behavior }); repo(ctx); @@ -103,7 +107,7 @@ test("Codex review runs in the Codex home with the job MCP list; --effort on the const result = companion(ctx, ["review", "--effort", "high", "--model", "gpt-review-pick"]); assert.equal(result.status, 0, result.stderr); - assert.ok(result.stdout.startsWith("note: effort high is ignored: Codex's native review takes no effort.\n\n# Codex Review\n"), result.stdout); + assert.ok(result.stdout.startsWith(`note: effort high is ignored: Codex's native review takes no effort.\nnote: ${NO_BUDGET("gpt-review-pick")}\n\n# Codex Review\n`), result.stdout); assert.match(result.stdout, /Reviewed uncommitted changes/); const state = ctx.fakeState(); // The caller's environment, unchanged: no CODEX_HOME is added. @@ -148,7 +152,7 @@ test("Codex adversarial review keeps outputSchema and its prompt, and passes --m assert.deepEqual(state.threadStarts.map(start => start.model), ["gpt-adversarial-pick"]); assert.equal(state.lastTurnStart.model, "gpt-adversarial-pick"); assert.equal(payload.result.findings[0].title, "Missing empty-state guard"); - assert.deepEqual(payload.notes, []); + assert.deepEqual(payload.notes, [NO_BUDGET("gpt-adversarial-pick")]); const turn = ctx.fakeState().lastTurnStart; assert.equal(turn.effort, "xhigh"); assert.match(turn.prompt, /^\nYou are Codex performing an adversarial software review\./); @@ -326,7 +330,7 @@ test("invalid review JSON fails the review on every backend and keeps the raw an const ctx = make(); const result = companion(ctx, ["adversarial-review", "--backend", backend]); assert.equal(result.status, 1, `${backend}: ${result.stdout}`); - assert.match(result.stdout, new RegExp(`^# ${label} Adversarial Review\n`), backend); + assert.ok(result.stdout.startsWith(`${lead(backend)}# ${label} Adversarial Review\n`), `${backend}: ${result.stdout}`); assert.ok(result.stdout.includes(`Raw final message:\n\n\`\`\`text\n${raw[backend]}\n\`\`\``), result.stdout); const job = onlyJob(ctx); assert.equal(job.state, "failed", backend); @@ -353,7 +357,7 @@ test("a failed adversarial-review turn says the turn failed with the backend's e const ctx = make(); const result = companion(ctx, ["adversarial-review", "--backend", backend]); assert.equal(result.status, 1, `${backend}: ${result.stdout}`); - assert.ok(result.stdout.startsWith(`# ${label} Adversarial Review\n\nTarget: working tree diff\n\nThe ${label} turn failed: ${error}\n`), `${backend}: ${result.stdout}`); + assert.ok(result.stdout.startsWith(`${lead(backend)}# ${label} Adversarial Review\n\nTarget: working tree diff\n\nThe ${label} turn failed: ${error}\n`), `${backend}: ${result.stdout}`); assert.doesNotMatch(result.stdout, /structured JSON|Parse error|no valid review JSON|unexpected review shape|^Verdict:/m, backend); if (backend === "codex") assert.ok(result.stdout.includes(`Raw final message:\n\n\`\`\`text\n{"verdict":"needs-attention"`), result.stdout); const job = onlyJob(ctx); diff --git a/tests/setup-page.test.mjs b/tests/setup-page.test.mjs index ef44fb78..700f3149 100644 --- a/tests/setup-page.test.mjs +++ b/tests/setup-page.test.mjs @@ -41,7 +41,7 @@ function page(ctx, args = [], env = {}) { return { status: result.status, stderr: result.stderr, page: result.stdout ? JSON.parse(result.stdout) : null }; } -test("the overview shows every backend home in one shape, ready, with no thread, kept session or agent", t => { +test("the overview shows shared backend fields and Codex budget metadata, ready, with no thread, kept session or agent", t => { const ctx = everyBackend(t, "home-probe"); const { status, page: report } = page(ctx); assert.equal(status, 0); @@ -53,7 +53,8 @@ test("the overview shows every backend home in one shape, ready, with no thread, ["codex", null, true], ["grok", null, true], ["grok", "jobs", true], ["zcode", null, true], ["kimi", null, true] ]); for (const block of report.backends) { - assert.deepEqual(Object.keys(block).sort(), BLOCK_KEYS, block.backend); + const keys = block.backend === "codex" ? [...BLOCK_KEYS, "toolOutputTokenLimit"].sort() : BLOCK_KEYS; + assert.deepEqual(Object.keys(block).sort(), keys, block.backend); assert.deepEqual(Object.keys(block.signIn).sort(), ["detail", "method", "state"]); assert.deepEqual(Object.keys(block.can).sort(), ["adversarialReview", "modes", "resume", "review", "steer", "stopGate", "transfer", "webSearchOff"]); } @@ -64,6 +65,7 @@ test("the overview shows every backend home in one shape, ready, with no thread, // The app-server reads the home's config.toml. assert.equal(codex.model, "gpt-default"); assert.equal(codex.effort, "high"); + assert.deepEqual(codex.toolOutputTokenLimit, { value: 65536, source: "Relay's deep budget for gpt-default (context window not reported, 272000 assumed)" }); assert.deepEqual(codex.models, [{ id: "gpt-fake-sol", efforts: ["low", "medium", "high"], defaultEffort: "low" }, { id: "gpt-fake-luna", efforts: ["low", "medium"], defaultEffort: "medium" }]); assert.equal(codex.can.stopGate, true); // Reviews run on Grok, ZCode and Kimi; the stop gate and transfer require Codex. diff --git a/tests/tool-output-budget.test.mjs b/tests/tool-output-budget.test.mjs new file mode 100644 index 00000000..bf96c267 --- /dev/null +++ b/tests/tool-output-budget.test.mjs @@ -0,0 +1,213 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildLaunchRequest, parseHeaderPrompt } from "../plugins/relay/scripts/lib/jobs/request.mjs"; +import { companion, finishJob, HEADER, makeHome, readJob, setup, startJob, waitFor } from "./support/job-helpers.mjs"; +import { initGitRepo } from "./support/helpers.mjs"; +import { TOOL_OUTPUT_BUDGETS } from "../plugins/relay/scripts/lib/backends/codex/request.mjs"; + +const VERSION = TOOL_OUTPUT_BUDGETS.version; + +test("Codex budgets validate before launch and are refused by other backends", t => { + const ctx = setup(t); + const build = value => buildLaunchRequest({ model: "gpt-test", effort: "low", toolOutputTokenLimit: value }, { baseDir: ctx.work, env: ctx.env }); + for (const value of ["", "0", "-1", "1.5", "1e5", "Infinity", "NaN", "9007199254740992", "default", "maximum"]) { + assert.throws(() => build(value), /tool-output-token-limit must be compact, balanced, deep, inherit or a positive safe integer/); + } + assert.equal(build("65536").toolOutputTokenLimit, 65536); + assert.equal(Object.hasOwn(build(undefined), "toolOutputTokenLimit"), false); + // A preset is stored as chosen and resolved when the thread opens. + assert.deepEqual(["Compact", "balanced", "deep", "inherit"].map(preset => [build(preset).toolOutputPreset, build(preset).toolOutputTokenLimit]), [["compact", undefined], ["balanced", undefined], ["deep", undefined], ["inherit", undefined]]); + const { header } = parseHeaderPrompt(`${HEADER}\ntool-output-token-limit: 131072 # tokens\n---\nReview`); + assert.equal(buildLaunchRequest(header, { baseDir: ctx.work, env: ctx.env }).toolOutputTokenLimit, 131072); + for (const backend of ["grok", "zcode", "kimi"]) { + assert.throws(() => buildLaunchRequest({ ...header, backend }, { baseDir: ctx.work, env: ctx.env }), /tool-output-token-limit is only valid with backend: codex/); + } +}); + +test("an explicit budget is stored, visible, and passed on start, continue and resume", async t => { + const ctx = setup(t); + const home = makeHome(ctx, ".codex", "tool_output_token_limit = 8192\n"); + const { id, start } = startJob(ctx, `${HEADER}\ntool-output-token-limit: 131072`); + assert.match(start, /^tool-output-token-limit: 131072$/m); + await finishJob(ctx, id); + fs.writeFileSync(path.join(home, "config.toml"), "tool_output_token_limit = 16384\n"); + for (const command of ["continue", "resume"]) { + const result = companion(ctx, [command, id, "--message", "[quick] Check another file."]); + assert.equal(result.status, 0, result.stderr); + await finishJob(ctx, id); + } + assert.equal(readJob(ctx, id).request.toolOutputTokenLimit, 131072); + const opens = ctx.fakeState().requests.filter(r => ["thread/start", "thread/resume"].includes(r.method)); + assert.deepEqual(opens.map(r => r.method), ["thread/start", "thread/resume", "thread/resume"]); + assert.deepEqual(opens.map(r => r.params.config.tool_output_token_limit), [131072, 131072, 131072]); + const status = companion(ctx, ["status", id, "--json"]); + assert.equal(JSON.parse(status.stdout).toolOutputTokenLimit, 131072); + assert.match(companion(ctx, ["result", id, "--state-only"]).stdout, /^tool-output-token-limit: 131072$/m); +}); + +test("without a budget, a table model gets its default budget on start and resume, with a note", async t => { + const ctx = setup(t); + const { id } = startJob(ctx, "model: gpt-6.1-sol\neffort: low"); + await finishJob(ctx, id); + assert.equal(companion(ctx, ["resume", id, "--message", "[quick] Continue."]).status, 0); + await finishJob(ctx, id); + assert.equal(Object.hasOwn(readJob(ctx, id).request, "toolOutputTokenLimit"), false); + const opens = ctx.fakeState().requests.filter(r => ["thread/start", "thread/resume"].includes(r.method)); + assert.deepEqual(opens.map(r => r.params.config.tool_output_token_limit), [131072, 131072]); + const state = companion(ctx, ["result", id, "--state-only"]).stdout; + assert.equal(state.match(/^note: tool-output-token-limit: .*$/gm).join("\n"), `note: tool-output-token-limit: 131072, Relay's deep budget for gpt-6.1-sol (reviewed ${VERSION}).`); +}); + +for (const [label, header, configRead, expected] of [ + ["Astra's default is balanced", "model: gpt-6-astra", { config: {} }, 65536], + ["Luna's default is deep", "model: gpt-6-luna", { config: {} }, 65536], + ["a default-model job takes the configured model's default", "model: default", { config: { model: "gpt-6.1-terra" } }, 131072], + ["a default-model job resolves its preset when the thread opens", "model: default\ntool-output-token-limit: compact", { config: { model: "gpt-6-luna" } }, 16384], + ["a budget in the Codex configuration wins over the default", "model: gpt-6.1-sol", { config: { tool_output_token_limit: 8192 } }, undefined], + ["a preset wins over the Codex configuration", "model: gpt-6.1-sol\ntool-output-token-limit: balanced", { config: { tool_output_token_limit: 8192 } }, 65536], + ["inherit sends no budget", "model: gpt-6.1-sol\ntool-output-token-limit: inherit", { config: {} }, undefined], + ["an unreviewed model without a known window", "model: gpt-test", { config: {} }, 65536], + ["an unreviewed model's catalog window", "model: gpt-wide", { config: {} }, 131072], + ["an unreviewed model's preset", "model: gpt-small\ntool-output-token-limit: compact", { config: {} }, 8192], + ["the configured context window", "model: gpt-test", { config: { model_context_window: 1000000 } }, 262144], + ["the configured context window under a named model's preset", "model: gpt-custom\ntool-output-token-limit: deep", { config: { model_context_window: 32768 } }, 8192], + ["the configured context window under a default model's preset", "model: default\ntool-output-token-limit: deep", { config: { model: "gpt-custom", model_context_window: 32768 } }, 8192], + ["the configured context window over a conflicting catalog entry", "model: gpt-wide\ntool-output-token-limit: deep", { config: { model_context_window: 32768 } }, 8192], + ["the configured context window over a reviewed model's values", "model: gpt-6.1-sol\ntool-output-token-limit: deep", { config: { model_context_window: 32768 } }, 8192], + ["a reviewed model's default under a configured context window", "model: gpt-6-astra", { config: { model_context_window: 1000000 } }, 262144] +]) { + test(`budget: ${label}`, async t => { + const ctx = setup(t, { env: { FAKE_CODEX_CONFIG_READ: JSON.stringify(configRead) } }); + fs.writeFileSync(path.join(makeHome(ctx), "models_cache.json"), JSON.stringify({ models: [{ slug: "gpt-wide", context_window: 500000 }, { slug: "gpt-small", context_window: 128000 }] })); + const { id } = startJob(ctx, `${header}\neffort: low`); + await finishJob(ctx, id); + assert.equal(ctx.fakeState().threadStarts[0].config.tool_output_token_limit, expected); + if (label.includes("without a known window")) { + assert.match(companion(ctx, ["result", id, "--state-only"]).stdout, /^note: tool-output-token-limit: 65536, Relay's deep budget for gpt-test \(context window not reported, 272000 assumed\)\.$/m); + } + if (label.includes("catalog window")) { + assert.match(companion(ctx, ["result", id, "--state-only"]).stdout, /^note: tool-output-token-limit: 131072, Relay's deep budget for gpt-wide \(context 500000 \/ 4\)\.$/m); + } + }); +} + +test("a native review without a budget gets the configured model's default", t => { + const ctx = setup(t, { behavior: "default", env: { FAKE_CODEX_CONFIG_READ: JSON.stringify({ config: { model: "gpt-6-astra" } }) } }); + initGitRepo(ctx.work); + fs.writeFileSync(path.join(ctx.work, "sample.txt"), "synthetic review input\n"); + const result = companion(ctx, ["review", "--scope", "working-tree"]); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(ctx.fakeState().threadStarts[0].config.tool_output_token_limit, 65536); +}); + +test("a replacement worker restores the stored budget after worker death", async t => { + const ctx = setup(t, { turnMs: 30000 }); + const { id } = startJob(ctx, `${HEADER}\ntool-output-token-limit: 65536`, "Read the synthetic input."); + await waitFor(() => readJob(ctx, id).turnId); + process.kill(readJob(ctx, id).worker.pid, "SIGKILL"); + await waitFor(() => companion(ctx, ["status", id]).stdout.match(/^state: dead$/m)); + const result = companion(ctx, ["resume", id], { FAKE_CODEX_TURN_MS: "50" }); + assert.equal(result.status, 0, result.stderr || result.stdout); + await finishJob(ctx, id); + const resumed = ctx.fakeState().requests.find(r => r.method === "thread/resume"); + assert.equal(resumed.params.config.tool_output_token_limit, 65536); + assert.equal(readJob(ctx, id).request.toolOutputTokenLimit, 65536); +}); + +test("start accepts the budget as a flag without a prompt header", async t => { + const ctx = setup(t); + const brief = path.join(ctx.root, "brief.txt"); + fs.writeFileSync(brief, "[quick] Review the synthetic input."); + const result = companion(ctx, ["start", "--model", "gpt-test", "--effort", "low", "--tool-output-token-limit", "65536", "--brief-file", brief, "--json"]); + assert.equal(result.status, 0, result.stderr); + const started = JSON.parse(result.stdout); + assert.equal(started.toolOutputTokenLimit, 65536); + await finishJob(ctx, started.jobId); + assert.equal(ctx.fakeState().threadStarts[0].config.tool_output_token_limit, 65536); +}); + +for (const command of ["task", "review", "adversarial-review"]) { + test(`${command} accepts and passes the Codex budget flag`, t => { + const ctx = setup(t, { behavior: "default" }); + initGitRepo(ctx.work); + fs.writeFileSync(path.join(ctx.work, "sample.txt"), "synthetic review input\n"); + const args = command === "task" ? ["[quick] Read sample.txt"] : ["--scope", "working-tree"]; + const result = companion(ctx, [command, "--tool-output-token-limit", "65536", ...args]); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(ctx.fakeState().threadStarts[0].config.tool_output_token_limit, 65536); + }); +} + +for (const [label, response, expected] of [ + ["user", { config: { tool_output_token_limit: 65536 }, origins: { tool_output_token_limit: { name: { type: "user", file: "/synthetic/config.toml" } } } }, { value: 65536, source: "user configuration (/synthetic/config.toml)" }], + ["project", { config: { tool_output_token_limit: 131072 }, origins: { tool_output_token_limit: { name: { type: "project", dotCodexFolder: "/synthetic/project/.codex" } } } }, { value: 131072, source: "project configuration (/synthetic/project/.codex)" }], + ["missing origin", { config: { tool_output_token_limit: 32768 } }, { value: 32768, source: "configuration (source not reported)" }], + ["unset on a table model", { config: { model: "gpt-6.1-sol" } }, { value: 131072, source: `Relay's deep budget for gpt-6.1-sol (reviewed ${VERSION})` }], + ["unset on another model", { config: {} }, { value: 65536, source: "Relay's deep budget for the default model (context window not reported, 272000 assumed)" }], + ["zero", { config: { tool_output_token_limit: 0 } }, { value: 0, source: "configuration (source not reported)" }], + ["invalid", { config: { tool_output_token_limit: "bad" } }, { value: null, source: "unknown (unrecognized configured budget)" }] +]) { + test(`setup reports ${label} budget without changing configuration or opening a thread`, t => { + const ctx = setup(t, { env: { FAKE_CODEX_CONFIG_READ: JSON.stringify(response) } }); + const home = makeHome(ctx); + const before = fs.readFileSync(path.join(home, "config.toml"), "utf8"); + const result = companion(ctx, ["setup", "--backend", "codex", "--json"]); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout).backends[0].toolOutputTokenLimit, expected); + const text = companion(ctx, ["setup", "--backend", "codex"]); + assert.equal(text.status, 0, text.stderr); + assert.ok(text.stdout.includes(expected.source), text.stdout); + assert.match(text.stdout, /^tool-output-token-limit:/m); + assert.equal(fs.readFileSync(path.join(home, "config.toml"), "utf8"), before); + assert.equal(ctx.fakeState().threadStarts?.length ?? 0, 0); + assert.equal(fs.realpathSync(ctx.fakeState().configReads[0].cwd), fs.realpathSync(ctx.work)); + assert.equal(ctx.fakeState().configReads[0].includeLayers, false); + }); +} + +for (const [label, header, configRead, expected] of [ + ["a budget above code mode's default tells the model how to ask for it", "model: gpt-6.1-sol", { config: {} }, { has: "\"max_output_tokens\": 131072" }], + ["a configured budget reaches code mode, after the user's developer instructions", "model: gpt-6.1-sol", { config: { tool_output_token_limit: 65536, developer_instructions: "Keep answers short." } }, { starts: "Keep answers short.\n\n", has: "\"max_output_tokens\": 65536" }], + ["a budget at or below code mode's default adds no instructions", "model: gpt-6.1-sol\ntool-output-token-limit: 8192", { config: {} }, null], + ["inherit without a configured budget adds no instructions", "model: gpt-6.1-sol\ntool-output-token-limit: inherit", { config: {} }, null] +]) { + test(`code mode: ${label}`, async t => { + const ctx = setup(t, { env: { FAKE_CODEX_CONFIG_READ: JSON.stringify(configRead) } }); + const { id } = startJob(ctx, `${header}\neffort: low`); + await finishJob(ctx, id); + assert.equal(companion(ctx, ["resume", id, "--message", "[quick] Continue."]).status, 0); + await finishJob(ctx, id); + const opens = ctx.fakeState().requests.filter(r => ["thread/start", "thread/resume"].includes(r.method)); + for (const open of opens) { + const instructions = open.params.developerInstructions; + if (!expected) { assert.equal(instructions, undefined, open.method); continue; } + assert.ok(instructions.includes(expected.has), instructions); + assert.match(instructions, /`wait`/); + if (expected.starts) assert.ok(instructions.startsWith(expected.starts), instructions); + } + }); +} + +test("a budget flag beside a prompt file wins over the file's header", async t => { + const ctx = setup(t); + const file = path.join(ctx.root, "prompt.md"); + fs.writeFileSync(file, `${HEADER}\ntool-output-token-limit: 131072\n---\n[quick] Read the synthetic input.`); + const result = companion(ctx, ["start", "--prompt-file", file, "--tool-output-token-limit", "65536", "--json"]); + assert.equal(result.status, 0, result.stderr); + const started = JSON.parse(result.stdout); + assert.equal(started.toolOutputTokenLimit, 65536); + await finishJob(ctx, started.jobId); + assert.equal(ctx.fakeState().threadStarts[0].config.tool_output_token_limit, 65536); +}); + +test("a native review prints the budget Relay chose and passes code mode's instructions", t => { + const ctx = setup(t, { behavior: "default", env: { FAKE_CODEX_CONFIG_READ: JSON.stringify({ config: { model: "gpt-6.1-sol" } }) } }); + initGitRepo(ctx.work); + fs.writeFileSync(path.join(ctx.work, "sample.txt"), "synthetic review input\n"); + const result = companion(ctx, ["review", "--scope", "working-tree"]); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, new RegExp(`^note: tool-output-token-limit: 131072, Relay's deep budget for gpt-6.1-sol \\(reviewed ${VERSION}\\)\\.$`, "m")); + assert.ok(ctx.fakeState().threadStarts[0].developerInstructions.includes("\"max_output_tokens\": 131072")); +});