diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2902e..4316b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Added Codex profile selection with `--profile`, including local, cron, tmux, named-session, Docker, and Modal execution while preserving explicit Headless model overrides. - Added remote Codex profile and model-catalog seeding plus automatic `SAKANA_API_KEY` forwarding for Fugu runs in Docker and Modal. - Hardened durable Docker profile refreshes with private host-owned profile directories, bounded regular-file reads, symlink-safe atomic replacement, and stale provider-config cleanup. +- Added normalized `--reasoning-effort max` support, including Codex's native `model_reasoning_effort="max"` setting and Cursor's parameterized `[effort=max]` model override. - Added an explicit Fast-mode opt-in for Codex and Claude: `--fast` in the CLI and `fast=False` sync/async Python SDK arguments. Standard mode is explicitly enforced by default, including scheduled, Docker, Modal, and newly launched tmux runs; existing tmux sessions reject Fast changes they cannot apply. - Fixed Antigravity one-shot runs to forward Headless's effective local, Docker, or Modal command timeout to `agy --print-timeout`, so long reasoning runs are not cut off by Agy's independent five-minute default. - Updated the bundled Docker image to Antigravity CLI 1.1.8, using the official release assets and published SHA-256 digests for Linux AMD64 and ARM64. diff --git a/config.toml.example b/config.toml.example index 8a76dea..5546c5c 100644 --- a/config.toml.example +++ b/config.toml.example @@ -19,7 +19,7 @@ model = "claude-opus-4-6" [agents.codex] model = "gpt-5.5" -# reasoning_effort = "xhigh" +# reasoning_effort = "max" # Use only with a model/backend that supports max. [agents.cursor] model = "gpt-5.5" diff --git a/docs/usage.md b/docs/usage.md index 6ad02a1..90d1490 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -59,7 +59,9 @@ Environment equivalents are also supported: `HEADLESS_ACP_AGENT`, `HEADLESS_ACP_ By default, Headless uses each agent's native auto-approve/bypass mode. Pass `--allow read-only` to use each agent's read-only or planning mode where available. Pass `--allow yolo` to request full tool access explicitly. -Pass `--reasoning-effort low|medium|high|xhigh` or `--effort low|medium|high|xhigh` to request a normalized reasoning effort for agents with native support. Claude receives `--effort`, Codex receives `model_reasoning_effort`, Cursor combines the model family and effort into Cursor's model variant string, OpenCode receives `--variant` in one-shot mode, and Pi receives `--thinking`. Docker and Modal inherit the same one-shot command. In tmux mode, Claude, Codex, Cursor, and Pi receive their interactive effort flags. Antigravity, Gemini, and OpenCode tmux currently accept the option, leave the command unchanged, and print a warning. +Pass `--reasoning-effort low|medium|high|xhigh|max` or `--effort low|medium|high|xhigh|max` to request a normalized reasoning effort for agents with native support. Claude receives `--effort`, Codex receives `model_reasoning_effort`, Cursor combines legacy model families and effort into Cursor model variants and uses its parameterized `[effort=max]` override for `max`, OpenCode receives `--variant` in one-shot mode, and Pi receives `--thinking`. Docker and Modal inherit the same one-shot command. In tmux mode, Claude, Codex, Cursor, and Pi receive their interactive effort flags. Antigravity, Gemini, and OpenCode tmux currently accept the option, leave the command unchanged, and print a warning. + +Effort availability remains model- and backend-specific. Headless forwards the selected value and does not silently downgrade it when the native backend rejects an unsupported effort. Fast mode is off by default and is controlled per invocation, not through `~/.headless/config.toml`. Pass `--fast` to opt into the provider's native Fast mode for Codex or Claude; other agents reject the flag. Headless explicitly sends Codex `service_tier="default"` or `service_tier="fast"`, and Claude `fastMode: false` or `true`, so an inherited provider config cannot silently enable Fast mode. The same option works for Docker, Modal, tmux, and `cron add` runs. @@ -291,7 +293,7 @@ Options: - `--model`, `--agent-model`: model override passed to the agent CLI. - `--profile`: Codex configuration profile for this invocation; persisted by named sessions and coordinated run nodes. - `--fast`: opt into Fast mode for Codex or Claude; off by default and not config-driven. -- `--reasoning-effort`, `--effort`: normalized reasoning effort, one of `low`, `medium`, `high`, or `xhigh`. +- `--reasoning-effort`, `--effort`: normalized reasoning effort, one of `low`, `medium`, `high`, `xhigh`, or `max`. - `--allow`: permission mode, either `read-only` or `yolo`. - `--acp-agent`: with `acp`, resolve an ACP server from the registry by id or name. - `--acp-command`: with `acp`, run a custom ACP server command such as `atlas alta agent run`. diff --git a/python/src/headless_cli/models.py b/python/src/headless_cli/models.py index 5e9cb8d..8800c9e 100644 --- a/python/src/headless_cli/models.py +++ b/python/src/headless_cli/models.py @@ -14,7 +14,7 @@ "acp", ] AllowMode = Literal["read-only", "yolo"] -ReasoningEffort = Literal["low", "medium", "high", "xhigh"] +ReasoningEffort = Literal["low", "medium", "high", "xhigh", "max"] Role = Literal["orchestrator", "explorer", "worker", "reviewer"] Coordination = Literal["session", "tmux", "oneshot"] RunStatus = Literal[ diff --git a/src/agents.ts b/src/agents.ts index a57a724..88f727e 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -85,8 +85,33 @@ function withCursorAllow(args: string[], allow: AllowMode | undefined): string[] return allow === "yolo" || allow === undefined ? [...args, "--force"] : args; } +function cursorBaseModel(model: string): string { + return model.replace(/-(low|medium|high|xhigh|extra-high|max)(-fast)?$/i, ""); +} + function isCursorReasoningVariant(model: string): boolean { - return /-(low|medium|high|xhigh|extra-high)(-fast)?$/i.test(model); + return cursorBaseModel(model) !== model; +} + +function withCursorReasoningEffort(model: string, effort: ReasoningEffort): string { + const openBracket = model.lastIndexOf("["); + if (openBracket === -1 || !model.endsWith("]")) { + return `${model}[effort=${effort}]`; + } + + const baseModel = model.slice(0, openBracket); + const parameters = model + .slice(openBracket + 1, -1) + .split(",") + .map((parameter) => parameter.trim()) + .filter(Boolean); + const effortIndex = parameters.findIndex((parameter) => /^effort\s*=/i.test(parameter)); + if (effortIndex >= 0) { + parameters[effortIndex] = `effort=${effort}`; + } else { + parameters.push(`effort=${effort}`); + } + return `${baseModel}[${parameters.join(",")}]`; } function supportsCursorReasoningVariants(model: string): boolean { @@ -120,10 +145,13 @@ function cursorReasoningVariant(model: string, effort: ReasoningEffort): string export function cursorModel(options: Pick): string { const model = options.model ?? DEFAULT_CURSOR_MODEL; - if (isCursorReasoningVariant(model)) return model; - if (!supportsCursorReasoningVariants(model)) return model; const effort = options.reasoningEffort ?? (options.model ? undefined : "medium"); if (!effort) return model; + if (effort === "max") { + return withCursorReasoningEffort(isCursorReasoningVariant(model) ? cursorBaseModel(model) : model, effort); + } + if (isCursorReasoningVariant(model)) return model; + if (!supportsCursorReasoningVariants(model)) return model; return cursorReasoningVariant(model, effort) ?? model; } diff --git a/src/cli.ts b/src/cli.ts index 149e9ff..a90d58a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -282,7 +282,7 @@ function usage(): string { " --profile Codex configuration profile.", " --fast Enable Fast mode for Codex or Claude.", " --no-fast Disable ambient Fast mode for Codex or Claude.", - " --reasoning-effort, --effort Reasoning effort: low, medium, high, or xhigh.", + " --reasoning-effort, --effort Reasoning effort: low, medium, high, xhigh, or max.", " --allow Permission mode: read-only or yolo.", " --acp-agent With acp, resolve an ACP server from the registry by id or name.", " --acp-command With acp, run a custom ACP server command, e.g. 'atlas alta agent run'.", @@ -733,7 +733,7 @@ function validateSafeName(value: string | undefined, label: string): string { } function parseReasoningEffort(value: string): ReasoningEffort { - if (value === "low" || value === "medium" || value === "high" || value === "xhigh") { + if (value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") { return value; } throw new CliError(`unsupported reasoning effort: ${value}`); diff --git a/src/config.ts b/src/config.ts index 346064c..f9c8a73 100644 --- a/src/config.ts +++ b/src/config.ts @@ -227,7 +227,7 @@ function parseRoleName(value: string, lineNumber: number): Role { } function parseConfigReasoningEffort(value: string, lineNumber: number): ReasoningEffort { - if (value === "low" || value === "medium" || value === "high" || value === "xhigh") { + if (value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") { return value; } throw new Error(`unsupported headless config reasoning_effort at line ${lineNumber}: ${value}`); diff --git a/src/types.ts b/src/types.ts index 41300ba..9c90eee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,7 @@ export type PromptFileMode = "argument" | "stdin"; export type AllowMode = "read-only" | "yolo"; -export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; +export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; export type Env = Record; diff --git a/src/usage.ts b/src/usage.ts index 757a5f6..8881d3e 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -569,10 +569,14 @@ function findPricingModel( function pricingModelCandidates(model: string): string[] { const candidates = [model]; - const cursorVariant = model.match(/^(.+?)-(extra-high|xhigh|medium|high|low)(-fast)?$/i); + const cursorVariant = model.match(/^(.+?)-(extra-high|xhigh|medium|high|low|max)(-fast)?$/i); if (cursorVariant?.[1]) { candidates.push(cursorVariant[1]); } + const cursorParameterized = model.match(/^(.+?)\[[^\]]*\]$/); + if (cursorParameterized?.[1]) { + candidates.push(cursorParameterized[1]); + } return candidates; } diff --git a/tests/allow.test.ts b/tests/allow.test.ts index 5536e3b..5ebe200 100644 --- a/tests/allow.test.ts +++ b/tests/allow.test.ts @@ -240,12 +240,12 @@ test("CLI rejects invalid allow mode", async () => { test("CLI rejects invalid reasoning effort", async () => { const stderr: string[] = []; - const code = await runCli(["codex", "--reasoning-effort", "max", "--prompt", "hello"], { + const code = await runCli(["codex", "--reasoning-effort", "unsupported", "--prompt", "hello"], { stderr: (text) => stderr.push(text), }); assert.equal(code, 2); - assert.match(stderr.join(""), /unsupported reasoning effort: max/); + assert.match(stderr.join(""), /unsupported reasoning effort: unsupported/); }); test("preserves ambient fast settings unless a mode is explicit", () => { @@ -366,6 +366,17 @@ test("CLI print-command includes reasoning effort flags", async () => { assert.match(stdout.join(""), /-c 'model_reasoning_effort="high"'/); }); +test("CLI accepts max reasoning effort", async () => { + const stdout: string[] = []; + const code = await runCli( + ["codex", "--model", "gpt-5.6", "--reasoning-effort", "max", "--prompt", "hello", "--print-command"], + { stdout: (text) => stdout.push(text) }, + ); + + assert.equal(code, 0); + assert.match(stdout.join(""), /--model gpt-5\.6 .*model_reasoning_effort="max"/); +}); + test("CLI accepts --effort as an alias for --reasoning-effort", async () => { const stdout: string[] = []; const code = await runCli(["codex", "--effort", "high", "--prompt", "hello", "--print-command"], { @@ -389,6 +400,25 @@ test("CLI maps Cursor reasoning effort to model variants", async () => { assert.equal(stderr.join(""), ""); }); +test("CLI maps Cursor max reasoning effort to a parameterized model", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const code = await runCli( + ["cursor", "--model", "gpt-5.6", "--reasoning-effort", "max", "--prompt", "hello", "--print-command"], + { + stdout: (text) => stdout.push(text), + stderr: (text) => stderr.push(text), + }, + ); + + assert.equal(code, 0); + assert.equal( + stdout.join(""), + "agent -p --trust --force --output-format stream-json --model 'gpt-5.6[effort=max]' hello\n", + ); + assert.equal(stderr.join(""), ""); +}); + test("CLI warns when Gemini reasoning effort is unsupported", async () => { const stdout: string[] = []; const stderr: string[] = []; diff --git a/tests/headless.test.ts b/tests/headless.test.ts index 8cbbdbe..9a9140f 100644 --- a/tests/headless.test.ts +++ b/tests/headless.test.ts @@ -292,6 +292,30 @@ test("builds reasoning effort flags for supported agents", () => { "-", ]); + assert.deepEqual(buildAgentCommand("codex", { prompt: "hello", reasoningEffort: "max" }, {}).args, [ + "--dangerously-bypass-approvals-and-sandbox", + "exec", + "--model", + "gpt-5.5", + "-c", + 'model_reasoning_effort="max"', + "--json", + "--skip-git-repo-check", + "-", + ]); + + const nativeMaxEffortFlags: Array<[AgentName, string]> = [ + ["claude", "--effort"], + ["opencode", "--variant"], + ["pi", "--thinking"], + ]; + for (const [agent, flag] of nativeMaxEffortFlags) { + const args = buildAgentCommand(agent, { prompt: "hello", reasoningEffort: "max" }, {}).args; + const flagIndex = args.indexOf(flag); + assert.notEqual(flagIndex, -1, `${agent} should receive ${flag}`); + assert.equal(args[flagIndex + 1], "max", `${agent} should receive max effort`); + } + assert.deepEqual(buildAgentCommand("claude", { prompt: "hello", reasoningEffort: "xhigh" }, {}).args, [ "--model", "claude-opus-4-6", @@ -349,6 +373,49 @@ test("maps Cursor reasoning effort to model variants and leaves Gemini unchanged args: ["-p", "--trust", "--force", "--output-format", "stream-json", "--model", "gpt-5.5-extra-high", "hello"], }); + assert.deepEqual(buildAgentCommand("cursor", { prompt: "hello", model: "gpt-5.6", reasoningEffort: "max" }, {}), { + command: "agent", + args: ["-p", "--trust", "--force", "--output-format", "stream-json", "--model", "gpt-5.6[effort=max]", "hello"], + }); + + assert.deepEqual( + buildAgentCommand("cursor", { prompt: "hello", model: "claude-opus-4-6", reasoningEffort: "max" }, {}), + { + command: "agent", + args: [ + "-p", + "--trust", + "--force", + "--output-format", + "stream-json", + "--model", + "claude-opus-4-6[effort=max]", + "hello", + ], + }, + ); + + assert.deepEqual( + buildAgentCommand( + "cursor", + { prompt: "hello", model: "gpt-5.6[context=1m,effort=high]", reasoningEffort: "max" }, + {}, + ), + { + command: "agent", + args: [ + "-p", + "--trust", + "--force", + "--output-format", + "stream-json", + "--model", + "gpt-5.6[context=1m,effort=max]", + "hello", + ], + }, + ); + assert.deepEqual(buildAgentCommand("cursor", { prompt: "hello", model: "gpt-5.5", reasoningEffort: "xhigh" }, {}), { command: "agent", args: ["-p", "--trust", "--force", "--output-format", "stream-json", "--model", "gpt-5.5-extra-high", "hello"], @@ -888,6 +955,15 @@ test("builds reasoning effort flags for supported interactive commands", () => { ], }); + assert.deepEqual(buildInteractiveAgentCommand("codex", { prompt: "hello", reasoningEffort: "max" }, {}).args, [ + "--dangerously-bypass-approvals-and-sandbox", + "--model", + "gpt-5.5", + "-c", + 'model_reasoning_effort="max"', + "hello", + ]); + assert.deepEqual(buildInteractiveAgentCommand("claude", { prompt: "hello", reasoningEffort: "xhigh" }, {}), { command: "claude", args: ["--model", "claude-opus-4-6", "--effort", "xhigh", "--dangerously-skip-permissions", "hello"], @@ -1219,8 +1295,12 @@ test("config parser accepts role sections and validates role fields", () => { assert.throws(() => parseHeadlessConfig("[roles.scout]\nallow = \"read-only\"\n"), /unsupported headless config role/); assert.throws(() => parseHeadlessConfig("[roles.explorer]\nunknown = \"value\"\n"), /unsupported headless role config key/); assert.throws(() => parseHeadlessConfig("[roles.explorer]\nallow = \"maybe\"\n"), /unsupported headless config allow/); + assert.equal( + parseHeadlessConfig("[roles.explorer]\nreasoning_effort = \"max\"\n").roles.explorer?.reasoningEffort, + "max", + ); assert.throws( - () => parseHeadlessConfig("[roles.explorer]\nreasoning_effort = \"max\"\n"), + () => parseHeadlessConfig("[roles.explorer]\nreasoning_effort = \"unsupported\"\n"), /unsupported headless config reasoning_effort/, ); }); diff --git a/tests/output.test.ts b/tests/output.test.ts index ed933cd..74c3f59 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -638,6 +638,46 @@ test("prices Cursor effort model variants with base model rates", () => { assert.equal(summary.pricingStatus, "priced"); }); +test("prices Cursor parameterized effort models with base model rates", () => { + const trace = JSON.stringify({ + type: "result", + usage: { + inputTokens: 1000, + outputTokens: 20, + cacheReadTokens: 400, + cacheWriteTokens: 0, + }, + }); + + const summary = priceUsageSummary( + extractUsageSummary("cursor", trace, { model: "gpt-5.6[effort=max]" }), + { + openai: { + models: { + "gpt-5.6": { + cost: { + input: 4, + cache_read: 0.4, + output: 20, + }, + }, + }, + }, + }, + ); + + assert.equal(summary.model, "gpt-5.6[effort=max]"); + assert.deepEqual(summary.cost, { + input: 0.004, + cacheRead: 0.00016, + cacheWrite: 0, + output: 0.0004, + total: 0.00456, + }); + assert.equal(summary.costBasis, "api-list-price-estimate"); + assert.equal(summary.pricingStatus, "priced"); +}); + test("extracts Gemini multi-model usage and sums priced costs", () => { const trace = JSON.stringify({ type: "result",