From 0f48347ca578bd70fe3fc3e2796de9878ac30fea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Fri, 30 Oct 2026 06:15:16 +0100 Subject: [PATCH 1/5] codex: make tool-output budgets explicit and visible --- README.md | 18 +++ docs/backends.md | 8 +- docs/design.md | 5 + plugins/relay/CHANGELOG.md | 5 + plugins/relay/commands/adversarial-review.md | 3 +- plugins/relay/commands/review.md | 3 +- plugins/relay/commands/setup.md | 5 + plugins/relay/scripts/cli/task.mjs | 5 +- plugins/relay/scripts/companion.mjs | 1 + .../scripts/lib/backends/codex/index.mjs | 7 +- .../scripts/lib/backends/codex/request.mjs | 16 ++- .../scripts/lib/backends/codex/review.mjs | 5 +- .../scripts/lib/backends/codex/setup.mjs | 18 ++- plugins/relay/scripts/lib/backends/types.d.ts | 3 + plugins/relay/scripts/lib/setup/page.mjs | 1 + plugins/relay/skills/jobs/SKILL.md | 2 + tests/fakes/codex.mjs | 5 + tests/setup-page.test.mjs | 6 +- tests/tool-output-budget.test.mjs | 119 ++++++++++++++++++ 19 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 tests/tool-output-budget.test.mjs diff --git a/README.md b/README.md index e6e5a406..2e6238a2 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: positive integer, tokens retained per tool result | inherited from Codex | | `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,23 @@ 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. Relay inherits it on each new job launch. `/relay:setup` reports the configured value +and its source; when unset, it reports a model default without guessing its numeric budget. +Setup reads this configuration; it does not write or freeze a default. + +To override the budget for one job, add `tool-output-token-limit: 65536` to its header. +The same `--tool-output-token-limit 65536` flag works with `start`, `task`, `review` and +`adversarial-review`. The override is stored with the job, shown in its state and JSON status, +and reapplied on continue, resume and worker recovery. Omit it to inherit Codex configuration. +Other backends reject this option. + +The example is a workload choice, not a model recommendation. Larger results use more context +and can cause earlier compaction. This budget does not increase the model's context window or +generated-answer limit, and cannot restore output already truncated by a tool. Keep model-specific +budgets in the configuration or workflow that chooses the model. 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..9c275e23 100644 --- a/docs/design.md +++ b/docs/design.md @@ -491,6 +491,11 @@ 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.** A job's optional `tool-output-token-limit` is stored in its launch + request and sent as `config.tool_output_token_limit` on thread start and resume, including + recovery. Native reviews pass it on their source thread too. Omission preserves Codex + configuration; setup reports the configured value and source through `config/read`. + 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/plugins/relay/CHANGELOG.md b/plugins/relay/CHANGELOG.md index 30cc19bd..f868a7b0 100644 --- a/plugins/relay/CHANGELOG.md +++ b/plugins/relay/CHANGELOG.md @@ -5,6 +5,11 @@ From a survey of every T3 Code thread on three machines, about 360 relay jobs from 17 to 22 October, most of them run by Workflow scripts ([docs/thread-surveys.md](../../docs/thread-surveys.md)). +- Codex jobs accept `tool-output-token-limit` in the header, or `--tool-output-token-limit` on + start, task and review commands. The per-result history budget is stored with the job and + reapplied on continue, resume and recovery. Omitting it preserves Codex configuration. + Setup reports the inherited value and source, or an unreported model default, without + changing configuration. Other backends reject the Codex-only option. - A Kimi job no longer ends `completed` with `report: none`. Kimi can write a turn's `turn.ended` before the engine's last batch of messages, the final answer included; relay dropped every message after `turn.ended`. A message now goes to the prompt turn of the engine turn running (for a diff --git a/plugins/relay/commands/adversarial-review.md b/plugins/relay/commands/adversarial-review.md index 0c0b99a7..e53770a2 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 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 ` overrides Codex's per-tool-result history budget for this review; omit it to inherit Codex configuration. 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..7c3b6aad 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 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 ` overrides Codex's per-tool-result history budget for this review; omit it to inherit Codex configuration. 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..101f3a32 100644 --- a/plugins/relay/commands/setup.md +++ b/plugins/relay/commands/setup.md @@ -36,6 +36,11 @@ 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. Setup only reads this budget; a model default +without a numeric value is not a measured limit. A job's `tool-output-token-limit` header +overrides the inherited budget 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/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..f4260522 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 ]", " 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/index.mjs b/plugins/relay/scripts/lib/backends/codex/index.mjs index 69010b02..541763fd 100644 --- a/plugins/relay/scripts/lib/backends/codex/index.mjs +++ b/plugins/relay/scripts/lib/backends/codex/index.mjs @@ -55,10 +55,13 @@ 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.toolOutputTokenLimit != null ? [`tool-output-token-limit: ${request.toolOutputTokenLimit}`] : []) + ]; }, lines() { return []; }, - json() { return {}; }, + json(request) { return request.toolOutputTokenLimit == null ? {} : { toolOutputTokenLimit: request.toolOutputTokenLimit }; }, 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/request.mjs b/plugins/relay/scripts/lib/backends/codex/request.mjs index 73958d20..687eb6b1 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"], @@ -29,6 +30,7 @@ export function codexDefaultHome(env = process.env) { // 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 +67,23 @@ 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 value = values.toolOutputTokenLimit; + const toolOutputTokenLimit = value == null ? null : Number(value); + if (value != null && (!/^\d+$/.test(String(value)) || !Number.isSafeInteger(toolOutputTokenLimit) || toolOutputTokenLimit <= 0)) { + throw new UsageError("tool-output-token-limit must be a positive safe integer (tokens)."); + } 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)), + ...(toolOutputTokenLimit == null ? {} : { toolOutputTokenLimit }) + }; } }; diff --git a/plugins/relay/scripts/lib/backends/codex/review.mjs b/plugins/relay/scripts/lib/backends/codex/review.mjs index 22c95901..bb828966 100644 --- a/plugins/relay/scripts/lib/backends/codex/review.mjs +++ b/plugins/relay/scripts/lib/backends/codex/review.mjs @@ -12,6 +12,7 @@ async function runAppServerReview(cwd, options = {}) { return withAppServer(cwd, async (client) => { const thread = await startThread(client, cwd, { model: options.model, + config: options.config, sandbox: "read-only", ephemeral: true }); @@ -62,7 +63,9 @@ 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, + config: request.toolOutputTokenLimit == null ? undefined : { tool_output_token_limit: request.toolOutputTokenLimit }, + 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..86e891a0 100644 --- a/plugins/relay/scripts/lib/backends/codex/setup.mjs +++ b/plugins/relay/scripts/lib/backends/codex/setup.mjs @@ -5,7 +5,7 @@ import { emptyBlock, notInstalled, isScript, firstLine, lastLine, seconds, close // 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: true, 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,14 @@ 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; + block.toolOutputTokenLimit = budget == null + ? { value: null, source: "model default (numeric budget not reported)" } + : 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 +140,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/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..0673816e 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: positive integer, tokens retained per tool result; stored and reapplied on continue, resume and recovery | inherited from Codex | | `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. +- For a Codex tool-output budget, use the value requested by the user or supplied by the workflow; otherwise omit the header to inherit Codex configuration. `/relay:setup` reports that configuration's value and source. The budget cannot recover output truncated by a tool before it returns, 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/setup-page.test.mjs b/tests/setup-page.test.mjs index ef44fb78..54b8fd59 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: null, source: "model default (numeric budget not reported)" }); 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..30a48161 --- /dev/null +++ b/tests/tool-output-budget.test.mjs @@ -0,0 +1,119 @@ +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"; + +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"]) { + assert.throws(() => build(value), /tool-output-token-limit must be a positive safe integer/); + } + assert.equal(build("65536").toolOutputTokenLimit, 65536); + assert.equal(Object.hasOwn(build(undefined), "toolOutputTokenLimit"), false); + 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("an omitted budget leaves Codex configuration in control on start and resume", async t => { + const ctx = setup(t); + const { id } = startJob(ctx); + 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); + for (const r of ctx.fakeState().requests.filter(r => ["thread/start", "thread/resume"].includes(r.method))) { + assert.equal(Object.hasOwn(r.params.config, "tool_output_token_limit"), false); + } +}); + +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)" }], + ["model default", { config: {} }, { value: null, source: "model default (numeric budget not reported)" }], + ["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, true); + }); +} From 69c7570be9761a2984a3e02f0754d746e2d09a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Fri, 30 Oct 2026 07:19:56 +0100 Subject: [PATCH 2/5] codex: a tool-output budget for every model, with presets (2.2.1) Codex keeps 10,000 tokens of each tool 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: `deep`, a quarter of the model's context window to the nearest power of two; `balanced` and `compact` are half and a quarter of it. Hand-reviewed models override the formula in tool-output-budgets.json. Jobs take a preset, a number or `inherit`; the state block names the budget and its basis. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 4 +- README.md | 53 ++++++++---- docs/design.md | 17 +++- package-lock.json | 4 +- package.json | 2 +- plugins/relay/.claude-plugin/plugin.json | 2 +- plugins/relay/CHANGELOG.md | 18 ++-- plugins/relay/commands/adversarial-review.md | 4 +- plugins/relay/commands/review.md | 4 +- plugins/relay/commands/setup.md | 7 +- plugins/relay/scripts/companion.mjs | 2 +- .../scripts/lib/backends/codex/connection.mjs | 14 ++-- .../scripts/lib/backends/codex/index.mjs | 9 +- .../scripts/lib/backends/codex/request.mjs | 83 +++++++++++++++++-- .../scripts/lib/backends/codex/review.mjs | 7 +- .../scripts/lib/backends/codex/setup.mjs | 8 +- .../backends/codex/tool-output-budgets.json | 36 ++++++++ plugins/relay/skills/jobs/SKILL.md | 4 +- tests/fixtures/codex-golden.json | 30 ++++--- tests/jobs.test.mjs | 4 +- tests/reviews-backends.test.mjs | 10 ++- tests/setup-page.test.mjs | 2 +- tests/tool-output-budget.test.mjs | 66 +++++++++++++-- 23 files changed, 300 insertions(+), 90 deletions(-) create mode 100644 plugins/relay/scripts/lib/backends/codex/tool-output-budgets.json 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 2e6238a2..b7194215 100644 --- a/README.md +++ b/README.md @@ -159,7 +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: positive integer, tokens retained per tool result | inherited from Codex | +| `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 `---` | @@ -252,21 +252,42 @@ 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. Relay inherits it on each new job launch. `/relay:setup` reports the configured value -and its source; when unset, it reports a model default without guessing its numeric budget. -Setup reads this configuration; it does not write or freeze a default. - -To override the budget for one job, add `tool-output-token-limit: 65536` to its header. -The same `--tool-output-token-limit 65536` flag works with `start`, `task`, `review` and -`adversarial-review`. The override is stored with the job, shown in its state and JSON status, -and reapplied on continue, resume and worker recovery. Omit it to inherit Codex configuration. -Other backends reject this option. - -The example is a workload choice, not a model recommendation. Larger results use more context -and can cause earlier compaction. This budget does not increase the model's context window or -generated-answer limit, and cannot restore output already truncated by a tool. Keep model-specific -budgets in the configuration or workflow that chooses the model. Codex CLI and Relay update -independently; a Relay update does not replace the `codex` executable used by jobs. +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. 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 resolved for the job's model at launch, stored with the +job, shown in its state and JSON status, and reapplied on continue, resume and worker recovery. +A preset on `model: default` resolves when the thread opens. Other backends reject this option. + +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 diff --git a/docs/design.md b/docs/design.md index 9c275e23..cb527672 100644 --- a/docs/design.md +++ b/docs/design.md @@ -491,10 +491,19 @@ 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.** A job's optional `tool-output-token-limit` is stored in its launch - request and sent as `config.tool_output_token_limit` on thread start and resume, including - recovery. Native reviews pass it on their source thread too. Omission preserves Codex - configuration; setup reports the configured value and source through `config/read`. +- **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. A job's `tool-output-token-limit` takes a preset, a number or + `inherit`. A preset on a named model resolves at launch and is stored with the number, so a later + table or catalog does not move the job; on `model: default` it resolves at thread open. 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. 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 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 f868a7b0..9bd60d97 100644 --- a/plugins/relay/CHANGELOG.md +++ b/plugins/relay/CHANGELOG.md @@ -1,15 +1,23 @@ # 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. +- 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 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, most of them run by Workflow scripts ([docs/thread-surveys.md](../../docs/thread-surveys.md)). -- Codex jobs accept `tool-output-token-limit` in the header, or `--tool-output-token-limit` on - start, task and review commands. The per-result history budget is stored with the job and - reapplied on continue, resume and recovery. Omitting it preserves Codex configuration. - Setup reports the inherited value and source, or an unreported model default, without - changing configuration. Other backends reject the Codex-only option. - A Kimi job no longer ends `completed` with `report: none`. Kimi can write a turn's `turn.ended` before the engine's last batch of messages, the final answer included; relay dropped every message after `turn.ended`. A message now goes to the prompt turn of the engine turn running (for a diff --git a/plugins/relay/commands/adversarial-review.md b/plugins/relay/commands/adversarial-review.md index e53770a2..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] [--tool-output-token-limit N] [--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,7 +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 ` overrides Codex's per-tool-result history budget for this review; omit it to inherit Codex configuration. Other backends reject it. +- `--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 7c3b6aad..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] [--tool-output-token-limit N] [--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,7 +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 ` overrides Codex's per-tool-result history budget for this review; omit it to inherit Codex configuration. Other backends reject it. +- `--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 101f3a32..d897a588 100644 --- a/plugins/relay/commands/setup.md +++ b/plugins/relay/commands/setup.md @@ -37,9 +37,10 @@ install offer below. Print every entry of a block's `warnings`; for Grok, a warn 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. Setup only reads this budget; a model default -without a numeric value is not a measured limit. A job's `tool-output-token-limit` header -overrides the inherited budget for that job. +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 diff --git a/plugins/relay/scripts/companion.mjs b/plugins/relay/scripts/companion.mjs index f4260522..7ba06329 100644 --- a/plugins/relay/scripts/companion.mjs +++ b/plugins/relay/scripts/companion.mjs @@ -27,7 +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 ]", + " 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..4f496bb3 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 }); 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 541763fd..f933a8e7 100644 --- a/plugins/relay/scripts/lib/backends/codex/index.mjs +++ b/plugins/relay/scripts/lib/backends/codex/index.mjs @@ -57,11 +57,16 @@ export const codexBackend = { const mcpOff = /** @type {string[] | undefined} */ (request.mcpOff); return [ ...(mcpOff?.length > 0 ? [`mcp-off: ${mcpOff.join(", ")}`] : []), - ...(request.toolOutputTokenLimit != null ? [`tool-output-token-limit: ${request.toolOutputTokenLimit}`] : []) + ...(request.toolOutputPreset || request.toolOutputTokenLimit != null ? [`tool-output-token-limit: ${[request.toolOutputPreset, request.toolOutputTokenLimit].filter(part => part != null).join(" ")}`] : []) ]; }, lines() { return []; }, - json(request) { return request.toolOutputTokenLimit == null ? {} : { toolOutputTokenLimit: request.toolOutputTokenLimit }; }, + 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/request.mjs b/plugins/relay/scripts/lib/backends/codex/request.mjs index 687eb6b1..9834945c 100644 --- a/plugins/relay/scripts/lib/backends/codex/request.mjs +++ b/plugins/relay/scripts/lib/backends/codex/request.mjs @@ -27,6 +27,78 @@ 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. +export function budgetsFor(model, { config = {}, codexHome }) { + if (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 on a +// named model resolves now, so a later table or catalog change does not move the job; on the +// default model it resolves when the thread opens. +function toolOutputFields(value, model, codexHome) { + if (value == null) return {}; + const text = String(value).trim().toLowerCase(); + if (text === "inherit") return { toolOutputPreset: "inherit" }; + if (TOOL_OUTPUT_PRESETS.includes(text)) { + if (!model) return { toolOutputPreset: text }; + return { toolOutputPreset: text, toolOutputTokenLimit: budgetsFor(model, { codexHome })[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 }; +} + +// The budget a thread opens with, and a note when Relay chose it: 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) { + if (request.toolOutputTokenLimit != null) return { limit: request.toolOutputTokenLimit, note: null }; + if (request.toolOutputPreset === "inherit") return { limit: null, note: null }; + let config = {}; + try { + config = (await client.request("config/read", { includeLayers: false, cwd: request.cwd }))?.config ?? {}; + } catch { + config = {}; + } + if (!request.toolOutputPreset && config.tool_output_token_limit != null) return { limit: null, note: 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 { limit: budgets[preset], note: `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 }; @@ -72,18 +144,11 @@ export const requestRules = { defaultEffort: null, refusals: {}, complete(request, values, { env, userConfig }) { - const value = values.toolOutputTokenLimit; - const toolOutputTokenLimit = value == null ? null : Number(value); - if (value != null && (!/^\d+$/.test(String(value)) || !Number.isSafeInteger(toolOutputTokenLimit) || toolOutputTokenLimit <= 0)) { - throw new UsageError("tool-output-token-limit must be a positive safe integer (tokens)."); - } + const budget = toolOutputFields(values.toolOutputTokenLimit, request.model && request.model !== "default" ? request.model : null, codexDefaultHome(env)); 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)), - ...(toolOutputTokenLimit == null ? {} : { toolOutputTokenLimit }) - }; + 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 bb828966..a4f344d5 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,9 +11,10 @@ async function runAppServerReview(cwd, options = {}) { } return withAppServer(cwd, async (client) => { + const budget = await toolOutputTokenLimitFor(client, options.request); const thread = await startThread(client, cwd, { model: options.model, - config: options.config, + config: budget.limit == null ? undefined : { tool_output_token_limit: budget.limit }, sandbox: "read-only", ephemeral: true }); @@ -63,8 +65,7 @@ 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, - config: request.toolOutputTokenLimit == null ? undefined : { tool_output_token_limit: request.toolOutputTokenLimit }, + model: request.model, request, notificationTap: hooks.tap, ...codexServerArgs(request) }); return { diff --git a/plugins/relay/scripts/lib/backends/codex/setup.mjs b/plugins/relay/scripts/lib/backends/codex/setup.mjs index 86e891a0..228cc3a0 100644 --- a/plugins/relay/scripts/lib/backends/codex/setup.mjs +++ b/plugins/relay/scripts/lib/backends/codex/setup.mjs @@ -1,6 +1,6 @@ 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. @@ -23,7 +23,7 @@ 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 result = await client.request("config/read", { includeLayers: true, cwd }); + 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; @@ -107,8 +107,10 @@ async function checkCodexHome(cwd, home, env, shared, limits) { 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: null, source: "model default (numeric budget not reported)" } + ? { 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)" }; 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/skills/jobs/SKILL.md b/plugins/relay/skills/jobs/SKILL.md index 0673816e..8acbdf79 100644 --- a/plugins/relay/skills/jobs/SKILL.md +++ b/plugins/relay/skills/jobs/SKILL.md @@ -47,13 +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: positive integer, tokens retained per tool result; stored and reapplied on continue, resume and recovery | inherited from Codex | +| `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. -- For a Codex tool-output budget, use the value requested by the user or supplied by the workflow; otherwise omit the header to inherit Codex configuration. `/relay:setup` reports that configuration's value and source. The budget cannot recover output truncated by a tool before it returns, and is separate from context size and answer length. +- 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/fixtures/codex-golden.json b/tests/fixtures/codex-golden.json index d4bc231a..58bfc227 100644 --- a/tests/fixtures/codex-golden.json +++ b/tests/fixtures/codex-golden.json @@ -15,7 +15,8 @@ "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 @@ -66,7 +67,8 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" + "web_search": "live", + "tool_output_token_limit": 65536 } } }, @@ -100,7 +102,8 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" + "web_search": "live", + "tool_output_token_limit": 65536 } } }, @@ -129,7 +132,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 +201,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 +295,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,7 +430,8 @@ "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 @@ -478,7 +482,8 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" + "web_search": "live", + "tool_output_token_limit": 65536 } } }, @@ -512,7 +517,8 @@ "approvalPolicy": "never", "sandbox": "danger-full-access", "config": { - "web_search": "live" + "web_search": "live", + "tool_output_token_limit": 65536 } } }, @@ -541,7 +547,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 +616,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 +710,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..907fd68f 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); @@ -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 54b8fd59..700f3149 100644 --- a/tests/setup-page.test.mjs +++ b/tests/setup-page.test.mjs @@ -65,7 +65,7 @@ test("the overview shows shared backend fields and Codex budget metadata, ready, // 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: null, source: "model default (numeric budget not reported)" }); + 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 index 30a48161..e3db48ec 100644 --- a/tests/tool-output-budget.test.mjs +++ b/tests/tool-output-budget.test.mjs @@ -5,15 +5,24 @@ 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"]) { - assert.throws(() => build(value), /tool-output-token-limit must be a positive safe integer/); + 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); + assert.deepEqual(["compact", "balanced", "deep"].map(preset => build(preset).toolOutputTokenLimit), [16384, 32768, 65536]); + const named = (model, value) => buildLaunchRequest({ model, effort: "low", toolOutputTokenLimit: value }, { baseDir: ctx.work, env: ctx.env }); + assert.deepEqual([named("gpt-6.1-sol", "Deep").toolOutputTokenLimit, named("gpt-6-astra", "balanced").toolOutputTokenLimit, named("gpt-6-luna", "compact").toolOutputTokenLimit], [131072, 65536, 16384]); + assert.equal(named("gpt-6.1-sol", "deep").toolOutputPreset, "deep"); + assert.equal(named("default", "deep").toolOutputTokenLimit, undefined); + assert.equal(named("gpt-test", "inherit").toolOutputPreset, "inherit"); 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"]) { @@ -42,16 +51,54 @@ test("an explicit budget is stored, visible, and passed on start, continue and r assert.match(companion(ctx, ["result", id, "--state-only"]).stdout, /^tool-output-token-limit: 131072$/m); }); -test("an omitted budget leaves Codex configuration in control on start and resume", async t => { +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); + 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); - for (const r of ctx.fakeState().requests.filter(r => ["thread/start", "thread/resume"].includes(r.method))) { - assert.equal(Object.hasOwn(r.params.config, "tool_output_token_limit"), 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] +]) { + 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 => { @@ -96,7 +143,8 @@ 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)" }], - ["model default", { config: {} }, { value: null, source: "model default (numeric budget 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)" }] ]) { @@ -114,6 +162,6 @@ for (const [label, response, expected] of [ 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, true); + assert.equal(ctx.fakeState().configReads[0].includeLayers, false); }); } From e2ab8049e76e90b789a0d3c17603423be3173b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Fri, 30 Oct 2026 07:47:39 +0100 Subject: [PATCH 3/5] codex: resolve presets after config/read, so a configured window bounds them A preset on a named model was resolved at launch, before Relay read the Codex configuration, so it ignored `model_context_window`: a custom model configured for 32k got `deep` = 65536. Every preset now resolves when the thread opens, after `config/read`, as presets on `model: default` and jobs without a preset already did. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- docs/design.md | 4 ++-- .../relay/scripts/lib/backends/codex/request.mjs | 16 ++++++---------- tests/tool-output-budget.test.mjs | 13 ++++++------- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b7194215..b3672ffb 100644 --- a/README.md +++ b/README.md @@ -280,9 +280,9 @@ budget a job on the default model gets and where it comes from. Setup never writ 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 resolved for the job's model at launch, stored with the -job, shown in its state and JSON status, and reapplied on continue, resume and worker recovery. -A preset on `model: default` resolves when the thread opens. Other backends reject this option. +`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. 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 diff --git a/docs/design.md b/docs/design.md index cb527672..8353214b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -498,8 +498,8 @@ binaries and never start a real backend. 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. A job's `tool-output-token-limit` takes a preset, a number or - `inherit`. A preset on a named model resolves at launch and is stored with the number, so a later - table or catalog does not move the job; on `model: default` it resolves at thread open. The number + `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:` diff --git a/plugins/relay/scripts/lib/backends/codex/request.mjs b/plugins/relay/scripts/lib/backends/codex/request.mjs index 9834945c..ef0096ca 100644 --- a/plugins/relay/scripts/lib/backends/codex/request.mjs +++ b/plugins/relay/scripts/lib/backends/codex/request.mjs @@ -62,17 +62,13 @@ export function budgetsFor(model, { config = {}, codexHome }) { }; } -// A header value: a positive token count, a preset, or `inherit` (no override). A preset on a -// named model resolves now, so a later table or catalog change does not move the job; on the -// default model it resolves when the thread opens. -function toolOutputFields(value, model, codexHome) { +// 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") return { toolOutputPreset: "inherit" }; - if (TOOL_OUTPUT_PRESETS.includes(text)) { - if (!model) return { toolOutputPreset: text }; - return { toolOutputPreset: text, toolOutputTokenLimit: budgetsFor(model, { codexHome })[text] }; - } + 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}".`); @@ -144,7 +140,7 @@ export const requestRules = { defaultEffort: null, refusals: {}, complete(request, values, { env, userConfig }) { - const budget = toolOutputFields(values.toolOutputTokenLimit, request.model && request.model !== "default" ? request.model : null, codexDefaultHome(env)); + 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.`); diff --git a/tests/tool-output-budget.test.mjs b/tests/tool-output-budget.test.mjs index e3db48ec..65be007e 100644 --- a/tests/tool-output-budget.test.mjs +++ b/tests/tool-output-budget.test.mjs @@ -17,12 +17,8 @@ test("Codex budgets validate before launch and are refused by other backends", t } assert.equal(build("65536").toolOutputTokenLimit, 65536); assert.equal(Object.hasOwn(build(undefined), "toolOutputTokenLimit"), false); - assert.deepEqual(["compact", "balanced", "deep"].map(preset => build(preset).toolOutputTokenLimit), [16384, 32768, 65536]); - const named = (model, value) => buildLaunchRequest({ model, effort: "low", toolOutputTokenLimit: value }, { baseDir: ctx.work, env: ctx.env }); - assert.deepEqual([named("gpt-6.1-sol", "Deep").toolOutputTokenLimit, named("gpt-6-astra", "balanced").toolOutputTokenLimit, named("gpt-6-luna", "compact").toolOutputTokenLimit], [131072, 65536, 16384]); - assert.equal(named("gpt-6.1-sol", "deep").toolOutputPreset, "deep"); - assert.equal(named("default", "deep").toolOutputTokenLimit, undefined); - assert.equal(named("gpt-test", "inherit").toolOutputPreset, "inherit"); + // 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"]) { @@ -75,7 +71,10 @@ for (const [label, header, configRead, expected] of [ ["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", "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] ]) { test(`budget: ${label}`, async t => { const ctx = setup(t, { env: { FAKE_CODEX_CONFIG_READ: JSON.stringify(configRead) } }); From b02ad2845b1308094c8d364b0920a6cada129f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Fri, 30 Oct 2026 08:24:46 +0100 Subject: [PATCH 4/5] codex: carry the tool-output budget into code mode; CodeRabbit fixes Code mode, which current Codex models use for every tool call, returns 10,000 tokens of each `exec` or `wait` result unless the call asks for more, whatever `tool_output_token_limit` is (DEFAULT_MAX_OUTPUT_TOKENS in Codex's unified_exec). A probe with a 65536 budget still kept 10k of 36k tokens. When a job's effective budget is above 10,000, the thread now opens with developer instructions naming the budget and the `// @exec: {"max_output_tokens": N}` pragma, after the configured developer instructions. Live, gpt-6-astra used the pragma and received a 36k-token result whole. From CodeRabbit: a --tool-output-token-limit flag wins over a prompt file's header, and native reviews print their budget note. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++ docs/design.md | 6 ++- plugins/relay/CHANGELOG.md | 8 +++- plugins/relay/scripts/cli/start.mjs | 5 ++- .../scripts/lib/backends/codex/connection.mjs | 2 +- .../scripts/lib/backends/codex/protocol.d.ts | 2 + .../scripts/lib/backends/codex/request.mjs | 28 +++++++++--- .../scripts/lib/backends/codex/review.mjs | 2 + .../relay/scripts/lib/backends/codex/turn.mjs | 6 ++- tests/fixtures/codex-golden.json | 18 +++++--- tests/reviews-backends.test.mjs | 2 +- tests/tool-output-budget.test.mjs | 45 +++++++++++++++++++ 12 files changed, 110 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b3672ffb..7c827831 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,12 @@ in charge. The same `--tool-output-token-limit` flag works with `start`, `task`, 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 diff --git a/docs/design.md b/docs/design.md index 8353214b..c81ac560 100644 --- a/docs/design.md +++ b/docs/design.md @@ -503,7 +503,11 @@ binaries and never start a real backend. 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. + 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 diff --git a/plugins/relay/CHANGELOG.md b/plugins/relay/CHANGELOG.md index 9bd60d97..f7911fd9 100644 --- a/plugins/relay/CHANGELOG.md +++ b/plugins/relay/CHANGELOG.md @@ -8,8 +8,14 @@ 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 budget is stored + 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. 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/lib/backends/codex/connection.mjs b/plugins/relay/scripts/lib/backends/codex/connection.mjs index 4f496bb3..bc01ee4f 100644 --- a/plugins/relay/scripts/lib/backends/codex/connection.mjs +++ b/plugins/relay/scripts/lib/backends/codex/connection.mjs @@ -33,7 +33,7 @@ class CodexConnection { threadId: sessionId, threadName: sessionName, ephemeral, model: request.model, serviceTier: request.fast ? "priority" : null, sandbox: sandboxFor(request), approvalPolicy: approvalPolicyFor(), - config + config, developerInstructions: budget.developerInstructions }); this.sessionId = opened.threadId; this.deliveredIds = clientIdsInThread(opened.response); 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 ef0096ca..09c70b2c 100644 --- a/plugins/relay/scripts/lib/backends/codex/request.mjs +++ b/plugins/relay/scripts/lib/backends/codex/request.mjs @@ -76,23 +76,37 @@ function toolOutputFields(value) { return { toolOutputTokenLimit: limit }; } -// The budget a thread opens with, and a note when Relay chose it: 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. +// 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) { - if (request.toolOutputTokenLimit != null) return { limit: request.toolOutputTokenLimit, note: null }; - if (request.toolOutputPreset === "inherit") return { limit: null, note: null }; let config = {}; try { config = (await client.request("config/read", { includeLayers: false, cwd: request.cwd }))?.config ?? {}; } catch { config = {}; } - if (!request.toolOutputPreset && config.tool_output_token_limit != null) return { limit: null, note: null }; + 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 { limit: budgets[preset], note: `tool-output-token-limit: ${budgets[preset]}, Relay's ${preset} budget for ${model ?? "the default model"} (${budgets.basis}).` }; + 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. diff --git a/plugins/relay/scripts/lib/backends/codex/review.mjs b/plugins/relay/scripts/lib/backends/codex/review.mjs index a4f344d5..fb54a242 100644 --- a/plugins/relay/scripts/lib/backends/codex/review.mjs +++ b/plugins/relay/scripts/lib/backends/codex/review.mjs @@ -12,9 +12,11 @@ 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 }); 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/tests/fixtures/codex-golden.json b/tests/fixtures/codex-golden.json index 58bfc227..fcdb62a9 100644 --- a/tests/fixtures/codex-golden.json +++ b/tests/fixtures/codex-golden.json @@ -19,7 +19,8 @@ "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." } }, { @@ -69,7 +70,8 @@ "config": { "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." } }, { @@ -104,7 +106,8 @@ "config": { "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." } }, { @@ -434,7 +437,8 @@ "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." } }, { @@ -484,7 +488,8 @@ "config": { "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." } }, { @@ -519,7 +524,8 @@ "config": { "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." } }, { diff --git a/tests/reviews-backends.test.mjs b/tests/reviews-backends.test.mjs index 907fd68f..322dc708 100644 --- a/tests/reviews-backends.test.mjs +++ b/tests/reviews-backends.test.mjs @@ -107,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. diff --git a/tests/tool-output-budget.test.mjs b/tests/tool-output-budget.test.mjs index 65be007e..3933f8af 100644 --- a/tests/tool-output-budget.test.mjs +++ b/tests/tool-output-budget.test.mjs @@ -164,3 +164,48 @@ for (const [label, response, expected] of [ 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")); +}); From 2e13da39fcb88af5ed77ccdd56e32e8e452cadf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Fri, 30 Oct 2026 08:37:50 +0100 Subject: [PATCH 5/5] codex: a configured context window bounds the reviewed models' presets too The reviewed values for named models assume the window Codex's catalog advertised. When the user configures `model_context_window`, the formula now applies to that window for every model, so a reviewed model configured for 32k gets `deep` = 8192 rather than 131072. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- docs/design.md | 3 ++- plugins/relay/scripts/lib/backends/codex/request.mjs | 7 +++++-- tests/tool-output-budget.test.mjs | 4 +++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7c827831..b0d61296 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,8 @@ result: - `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. Models reviewed by hand override the formula in +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 | diff --git a/docs/design.md b/docs/design.md index c81ac560..553825e5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -497,7 +497,8 @@ binaries and never start a real backend. `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. A job's `tool-output-token-limit` takes a preset, a number or + 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 diff --git a/plugins/relay/scripts/lib/backends/codex/request.mjs b/plugins/relay/scripts/lib/backends/codex/request.mjs index 09c70b2c..29d60ee4 100644 --- a/plugins/relay/scripts/lib/backends/codex/request.mjs +++ b/plugins/relay/scripts/lib/backends/codex/request.mjs @@ -49,9 +49,12 @@ function contextWindowFor(model, config, codexHome) { } } -// A model's presets, its default preset and where they come from. +// 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 }) { - if (model && Object.hasOwn(TOOL_OUTPUT_BUDGETS.models, model)) { + 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); diff --git a/tests/tool-output-budget.test.mjs b/tests/tool-output-budget.test.mjs index 3933f8af..bf96c267 100644 --- a/tests/tool-output-budget.test.mjs +++ b/tests/tool-output-budget.test.mjs @@ -74,7 +74,9 @@ for (const [label, header, configRead, expected] of [ ["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 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) } });