From 86b745b48b198f79fc328d1a29e7f362e4b1c037 Mon Sep 17 00:00:00 2001 From: Michel Moreira Date: Sat, 5 Sep 2026 15:20:26 -0300 Subject: [PATCH] fix(#4270): forward Codex spawn model routing (#4281) Co-authored-by: Tom Boucher --- .changeset/silly-geese-hop.md | 5 +++ bin/install.js | 39 +++++++++++++------- docs/CONFIGURATION.md | 4 +- docs/adr/2313-codex-passive-model-posture.md | 15 ++++++++ docs/how-to/configure-model-profiles.md | 23 +++++++----- src/runtime-artifact-conversion.cts | 39 +++++++++++++------- tests/codex-config.test.cjs | 9 ++++- tests/install.test.cjs | 25 ++++++++++--- 8 files changed, 114 insertions(+), 45 deletions(-) create mode 100644 .changeset/silly-geese-hop.md diff --git a/.changeset/silly-geese-hop.md b/.changeset/silly-geese-hop.md new file mode 100644 index 000000000..251368f11 --- /dev/null +++ b/.changeset/silly-geese-hop.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4281 +--- +Forward Codex adaptive per-agent model and reasoning-effort routing through supported spawn_agent fields while preserving inheritance fallback for older schemas. (#4270) diff --git a/bin/install.js b/bin/install.js index e312c8966..cbcdedf55 100755 --- a/bin/install.js +++ b/bin/install.js @@ -3890,26 +3890,37 @@ Execute mode fallback: ## C. Task() → spawn_agent Mapping GSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools: -**Schema detection (required first step):** Codex exposes two \`spawn_agent\` schemas: -- **agent_type-capable schema** (e.g. \`multi_agent_v2\`): \`spawn_agent\` accepts \`agent_type\`, \`message\`, \`reasoning_effort\`, \`fork_context\`, etc. — typed GSD agent dispatch is available. -- **Generic schema** (\`multi_agent_v1\`): \`spawn_agent\` accepts only \`message\`, \`items\`, \`fork_context\` — there is **no \`agent_type\` field**. Typed GSD agent dispatch is unavailable in this session. +**Schema detection (required first step):** Before spawning, inspect the \`spawn_agent\` +tool's visible parameter schema (via \`tool_search\` or the tool list). Use the presence +of \`agent_type\` only to choose typed dispatch versus the generic-agent workaround. +Detect optional fields independently: \`model\`, \`reasoning_effort\`, \`task_name\`, +\`fork_turns\`, and \`fork_context\` may be added or removed without \`agent_type\` changing. +Never infer one field from a schema/version label or from the presence of another field. -Before spawning, inspect the \`spawn_agent\` tool's visible parameter schema (via \`tool_search\` or the tool list) to determine which form is active. +- **agent_type-capable schema:** \`spawn_agent\` advertises \`agent_type\` — typed GSD agent dispatch is available. +- **Generic schema:** \`spawn_agent\` does not advertise \`agent_type\` — typed GSD agent dispatch is unavailable in this session, even if other optional fields are present. Typed mapping (agent_type-capable schema only): - \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\` - \`Agent(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\` -- \`Task(model="...")\` → omit. \`spawn_agent\` has no inline \`model\` parameter; - GSD embeds the resolved per-agent model directly into each agent's \`.toml\` - at install time so \`model_overrides\` from \`.planning/config.json\` and - \`~/.gsd/defaults.json\` are honored automatically by Codex's agent router. -- Resolved \`reasoning_effort="low|medium|high|xhigh"\` (\`xhigh\` is a GSD/Codex tier, not a generic runtime enum) → pass \`reasoning_effort\` - to \`spawn_agent\` when the runtime/tool supports it. Omit missing, empty, - inherited, or unsupported values; do not invent one-off effort literals in - workflow prose. +- \`Task(model="{resolved_model}")\` → pass \`model="{resolved_model}"\` when the + visible \`spawn_agent\` schema advertises \`model\` and the resolved value is explicit. + This is how \`model_profile\` tier routing, including \`adaptive\`, reaches the child agent. + Omit \`model\` only when the schema does not advertise \`model\`, or when the value is + missing, empty, or \`"inherit"\`; omission deliberately inherits the session/static agent + configuration. Explicit \`model_overrides\` may also be embedded in agent \`.toml\` files, + but ordinary profile-resolved models are not, so a TOML file is not a reason to discard + an available inline value. +- Before each typed spawn, obtain the paired effort for its role with + \`gsd_run query resolve-model --pick effort\` when the workflow has not + already exposed it. The resolver's unified \`effort\` field maps to the Codex spawn argument + \`reasoning_effort\`; do not look for a resolver field named \`reasoning_effort\`. + Pass it when the visible \`spawn_agent\` schema advertises \`reasoning_effort\`. Omit the + field when it is not advertised, or when the value is missing, empty, \`"inherit"\`, or + unsupported; do not invent one-off effort literals in workflow prose. - \`fork_context: false\` by default — GSD agents load their own context via \`\` blocks -- \`task_name\` — required by the collaboration schema; provide a descriptive name for each spawned task -- \`fork_turns\` — optional parameter controlling turn-forking depth; coexists with \`fork_context\` (not a replacement) +- \`task_name\` — when advertised, provide a descriptive name for each spawned task +- \`fork_turns\` — when advertised, controls turn-forking depth; coexists with \`fork_context\` (not a replacement) - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping, but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex \`spawn_agent\` still does not create or bind a git worktree; instead GSD itself diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 454cc984d..0b695edad 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -227,7 +227,7 @@ derived from the shipped agent declaration. | `granularity` | enum | `coarse`, `standard`, `fine` | `standard` | Controls phase count: `coarse` (2-4), `standard` (4-6), `fine` (6-10) | | `agent_tools.` | string[] | tool names meeting the [agent tool grant validation rules](#agent-tool-grants) | (none) | Additive install-time grants for `"*"` or a named agent. A project selector replaces the corresponding global selector; wildcard grants precede named grants. Re-run `gsd install ` after changing it. | | `model_profile` | enum | `quality`, `balanced`, `budget`, `adaptive`, `inherit` | `balanced` | Model tier for each agent (see [Model Profiles](#model-profiles)). `adaptive` was added per [#1713](https://github.com/open-gsd/gsd-core/issues/1713) / [#1806](https://github.com/open-gsd/gsd-core/issues/1806) and resolves the same way as the other tiers under runtime-aware profiles. | -| `runtime` | string | `claude`, `codex`, or any string | (none) | Active runtime for [runtime-aware profile resolution](#runtime-aware-profiles-2517). When set, profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs. The resolved ID is embedded into each agent's static frontmatter at install time on `opencode` (whose `spawn_agent` interface does not accept an inline `model` parameter, so editing `model_overrides` requires re-running `gsd install ` to take effect — see [Per-Agent Overrides](#per-agent-overrides)); other runtimes consume the resolver at spawn time. **`codex` is the exception: it embeds no per-tier model at all.** Codex is a passive / session-only model host ([ADR-2313](adr/2313-codex-passive-model-posture.md)) — a ChatGPT-account session exposes only its own model, so a pinned tier model returns `400 invalid_request_error` and the agent fails to spawn. Codex agents therefore inherit the session model, and only an explicit real-Codex id in `model_overrides` (e.g. `"gpt-5.6-sol"`) is written into the `.toml`. When unset (default), model resolution is unchanged from prior versions — but the runtime GSD *reports* (`agent_runtime`) then falls through to [host detection](how-to/control-the-reported-host-runtime.md), which can resolve `codex` from Codex's own session environment. Detection affects reporting and the agent-installation check only; it never feeds tier resolution, which still reads this key alone. Added in v1.39; Codex behavior changed in v1.11; reporting-only host detection added in v1.11 | +| `runtime` | string | `claude`, `codex`, or any string | (none) | Active runtime for [runtime-aware profile resolution](#runtime-aware-profiles-2517). When set, profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs. The resolved ID is embedded into each agent's static frontmatter at install time on `opencode` (whose `spawn_agent` interface does not accept an inline `model` parameter, so editing `model_overrides` requires re-running `gsd install ` to take effect — see [Per-Agent Overrides](#per-agent-overrides)); other runtimes consume the resolver at spawn time. **Codex keeps profile-resolved models out of static TOML and transports them conditionally at spawn time.** A Codex skill passes the resolved `model` and `reasoning_effort` only when the visible `spawn_agent` schema advertises each field; otherwise it omits that field and inherits the session/static agent configuration. Explicit real-Codex IDs in `model_overrides` (for example `"gpt-5.6-sol"`) are still written into `.toml` as a fallback. When unset (default), model resolution is unchanged from prior versions — but the runtime GSD *reports* (`agent_runtime`) then falls through to [host detection](how-to/control-the-reported-host-runtime.md), which can resolve `codex` from Codex's own session environment. Detection affects reporting and the agent-installation check only; it never feeds tier resolution, which still reads this key alone. Added in v1.39; Codex static posture changed in v1.11; reporting-only host detection added in v1.11 | | `model_profile_overrides..` | string \| object | per-runtime tier override | (none) | Override the runtime-aware tier mapping for a specific `(runtime, tier)`. Tier is one of `opus`, `sonnet`, `haiku`. Value is either a model ID string (e.g. `"gpt-5-pro"`) or `{ model, reasoning_effort }`. See [Runtime-Aware Profiles](#runtime-aware-profiles-2517). Added in v1.39 | | `model_policy.provider` | string | `openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`, `generic` | (none) | Declares the model provider. Known providers (`openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`) unlock catalog-backed presets. `generic` treats all model IDs as opaque strings — no prefix inference, no reasoning-effort defaults. `model_policy.runtime_tiers` resolves before legacy `model_profile_overrides`. See [Model Policy Presets](#model-policy-presets-model_policy--added-in-v142). Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) | | `model_policy.budget` | enum | `high`, `medium`, `low` | (none) | Selects a budget tier when using a known provider. GSD materializes the matching catalog preset into explicit tier mappings at resolve time. Ignored when `provider` is `generic` or `custom`. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) | @@ -2007,7 +2007,7 @@ When `runtime` is set, profile tiers (`opus`/`sonnet`/`haiku`) resolve to runtim } ``` -This resolves `gsd-planner` → `gpt-5.6-sol` (xhigh), `gsd-executor` → `gpt-5.6-terra` (medium), `gsd-codebase-mapper` → `gpt-5.6-luna` (medium). The Codex installer embeds `model = "..."` and `model_reasoning_effort = "..."` in each generated agent TOML. +This resolves `gsd-planner` → `gpt-5.6-sol` (xhigh), `gsd-executor` → `gpt-5.6-terra` (medium), `gsd-codebase-mapper` → `gpt-5.6-luna` (medium). Codex skills pass each resolved `model` and `reasoning_effort` to `spawn_agent` when its visible schema advertises the corresponding field; otherwise they omit the field and inherit the session/static agent configuration. **Claude example** — explicit opt-in resolves to full Claude IDs (no `resolve_model_ids: true` needed): diff --git a/docs/adr/2313-codex-passive-model-posture.md b/docs/adr/2313-codex-passive-model-posture.md index c16c2d862..4a330477a 100644 --- a/docs/adr/2313-codex-passive-model-posture.md +++ b/docs/adr/2313-codex-passive-model-posture.md @@ -334,3 +334,18 @@ is silently no-pin, matching how `""` already behaved. This ADR's D2 ("embed a `model` only for an explicit real-Codex pin") always implied this. The implementation simply did not enforce it, and no test covered the case. + +## Amendment (2026-09-04): capability-gated invocation-time routing (#4270) + +Codex now exposes `model` and `reasoning_effort` on some `spawn_agent` schemas. This is the +invocation-time capability signal that did not exist when this ADR adopted a session-only posture. +GSD therefore passes a workflow's resolved values on an individual spawn when — and only when — +the visible schema advertises each field. The fields are detected independently from each other +and from `agent_type`; absent fields, empty values, and `"inherit"` continue to degrade to session +or static agent configuration. + +This amendment does not reverse D1–D4 for the static/install-time channel. Profile-resolved values +remain absent from generated TOML, explicit `model_overrides` pins remain the only static model +transport, and effort remains coupled to a static pin there. It supersedes only the broader claim +that Codex has no tier routing: capable schemas now route at invocation time, while older schemas +retain the passive fallback. diff --git a/docs/how-to/configure-model-profiles.md b/docs/how-to/configure-model-profiles.md index cef73192f..5e10da375 100644 --- a/docs/how-to/configure-model-profiles.md +++ b/docs/how-to/configure-model-profiles.md @@ -185,17 +185,21 @@ quota / rate-limit failures; other failures keep the tier ladder. Leaving ## Using GSD on non-Anthropic runtimes -If you installed GSD for Codex, OpenCode, Antigravity CLI, or Kilo, the installer already set `resolve_model_ids: "omit"` in your config. This tells GSD to skip Anthropic model ID resolution and let the runtime choose its own default model. No manual setup is needed for the basic case. +If you installed GSD for Codex, OpenCode, Antigravity CLI, or Kilo, the installer already set `resolve_model_ids: "omit"` in your config. This prevents unresolved Anthropic model IDs from leaking into those runtimes. When `runtime` is set, runtime-native profile resolution still supplies any model and effort that the runtime adapter can transport. No manual setup is needed for the basic case. -### Codex does not do tier routing — pin explicitly instead +### Codex routes tiers at spawn time when supported -**Codex agents inherit whatever model your Codex session is using.** GSD writes no `model` line into -`~/.codex/agents/.toml`, so setting `model_profile` has no effect on Codex. +GSD deliberately writes no profile-resolved `model` line into +`~/.codex/agents/.toml` ([ADR-2313](../adr/2313-codex-passive-model-posture.md)). +Instead, each Codex skill inspects the visible `spawn_agent` schema. When that schema advertises +`model` and `reasoning_effort`, the skill passes the model and effort resolved from +`model_profile` — including `adaptive` — on that individual spawn. When either field is absent, +the skill omits that field and the child inherits the session or static agent configuration. -This is deliberate ([ADR-2313](../adr/2313-codex-passive-model-posture.md)). A ChatGPT-account Codex -session exposes only its own model, so a pinned tier model fails the request outright — -`400 invalid_request_error: "The 'sonnet' model is not supported when using Codex with a ChatGPT -account"` — and the agent never spawns. +This keeps compatibility with older Codex schemas while allowing newer installations to route +`gsd-planner`, `gsd-executor`, and other roles to their configured tiers. The fields are detected +independently; support for typed `agent_type` dispatch does not imply support for either routing +field. **To pin a model on Codex, name a real Codex model id per agent:** @@ -209,7 +213,8 @@ account"` — and the agent never spawns. } ``` -Then re-run the installer, as with any `model_overrides` edit on Codex (see above). +Then re-run the installer to materialize the override in the agent TOML as a fallback for spawn +schemas that do not advertise inline `model` (see above). Two rules apply to what you can put there: diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index c7e7c344e..3dce8375b 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -1884,26 +1884,37 @@ Execute mode fallback: ## C. Task() → spawn_agent Mapping GSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools: -**Schema detection (required first step):** Codex exposes two \`spawn_agent\` schemas: -- **agent_type-capable schema** (e.g. \`multi_agent_v2\`): \`spawn_agent\` accepts \`agent_type\`, \`message\`, \`reasoning_effort\`, \`fork_context\`, etc. — typed GSD agent dispatch is available. -- **Generic schema** (\`multi_agent_v1\`): \`spawn_agent\` accepts only \`message\`, \`items\`, \`fork_context\` — there is **no \`agent_type\` field**. Typed GSD agent dispatch is unavailable in this session. +**Schema detection (required first step):** Before spawning, inspect the \`spawn_agent\` +tool's visible parameter schema (via \`tool_search\` or the tool list). Use the presence +of \`agent_type\` only to choose typed dispatch versus the generic-agent workaround. +Detect optional fields independently: \`model\`, \`reasoning_effort\`, \`task_name\`, +\`fork_turns\`, and \`fork_context\` may be added or removed without \`agent_type\` changing. +Never infer one field from a schema/version label or from the presence of another field. -Before spawning, inspect the \`spawn_agent\` tool's visible parameter schema (via \`tool_search\` or the tool list) to determine which form is active. +- **agent_type-capable schema:** \`spawn_agent\` advertises \`agent_type\` — typed GSD agent dispatch is available. +- **Generic schema:** \`spawn_agent\` does not advertise \`agent_type\` — typed GSD agent dispatch is unavailable in this session, even if other optional fields are present. Typed mapping (agent_type-capable schema only): - \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\` - \`Agent(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\` -- \`Task(model="...")\` → omit. \`spawn_agent\` has no inline \`model\` parameter; - GSD embeds the resolved per-agent model directly into each agent's \`.toml\` - at install time so \`model_overrides\` from \`.planning/config.json\` and - \`~/.gsd/defaults.json\` are honored automatically by Codex's agent router. -- Resolved \`reasoning_effort="low|medium|high|xhigh"\` (\`xhigh\` is a GSD/Codex tier, not a generic runtime enum) → pass \`reasoning_effort\` - to \`spawn_agent\` when the runtime/tool supports it. Omit missing, empty, - inherited, or unsupported values; do not invent one-off effort literals in - workflow prose. +- \`Task(model="{resolved_model}")\` → pass \`model="{resolved_model}"\` when the + visible \`spawn_agent\` schema advertises \`model\` and the resolved value is explicit. + This is how \`model_profile\` tier routing, including \`adaptive\`, reaches the child agent. + Omit \`model\` only when the schema does not advertise \`model\`, or when the value is + missing, empty, or \`"inherit"\`; omission deliberately inherits the session/static agent + configuration. Explicit \`model_overrides\` may also be embedded in agent \`.toml\` files, + but ordinary profile-resolved models are not, so a TOML file is not a reason to discard + an available inline value. +- Before each typed spawn, obtain the paired effort for its role with + \`gsd_run query resolve-model --pick effort\` when the workflow has not + already exposed it. The resolver's unified \`effort\` field maps to the Codex spawn argument + \`reasoning_effort\`; do not look for a resolver field named \`reasoning_effort\`. + Pass it when the visible \`spawn_agent\` schema advertises \`reasoning_effort\`. Omit the + field when it is not advertised, or when the value is missing, empty, \`"inherit"\`, or + unsupported; do not invent one-off effort literals in workflow prose. - \`fork_context: false\` by default — GSD agents load their own context via \`\` blocks -- \`task_name\` — required by the collaboration schema; provide a descriptive name for each spawned task -- \`fork_turns\` — optional parameter controlling turn-forking depth; coexists with \`fork_context\` (not a replacement) +- \`task_name\` — when advertised, provide a descriptive name for each spawned task +- \`fork_turns\` — when advertised, controls turn-forking depth; coexists with \`fork_context\` (not a replacement) - \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct \`spawn_agent\` mapping, but Codex declares \`dispatch.isolation: orchestrator-worktree\` (#2584). Codex \`spawn_agent\` still does not create or bind a git worktree; instead GSD itself diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index b6a8e724d..b5ada431f 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -220,9 +220,16 @@ describe('getCodexSkillAdapterHeader', () => { const result = getCodexSkillAdapterHeader('gsd-execute-phase'); assert.ok(result.includes('spawn_agent'), 'maps to spawn_agent'); assert.ok(result.includes('agent_type'), 'maps subagent_type to agent_type'); + // #4270: resolve-model exposes the portable field as `effort`; the Codex + // adapter must fetch it and translate it to spawn_agent.reasoning_effort. assert.match( result, - /Resolved `reasoning_effort="low\|medium\|high\|xhigh"` \(`xhigh` is a GSD\/Codex tier, not a generic runtime enum\) → pass `reasoning_effort`\s+to `spawn_agent` when the runtime\/tool supports it/, + /query resolve-model --pick effort/, + 'retrieves the unified effort for the dispatched role', + ); + assert.match( + result, + /unified `effort` field maps to the Codex spawn argument\s+`reasoning_effort`/, 'documents reasoning_effort transport', ); assert.ok(result.includes('do not invent one-off effort literals'), 'keeps effort policy centralized'); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index cdf081992..3b356f8ae 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -2347,15 +2347,30 @@ describe('bug #2256 — OpenCode adapter embeds per-project override', () => { }); describe('bug #2256 — Codex skill adapter header documents transport', () => { - test('Task(model=...) line no longer says "omit" without explanation', () => { + test('documents static model_overrides as a fallback transport', () => { const header = getCodexSkillAdapterHeader('gsd-plan-phase'); - // Header must mention that per-agent model_overrides are embedded in agent - // TOML so spawn_agent picks them up automatically — the old text said - // "Codex uses per-role config, not inline model selection" which left - // users thinking their model_overrides were silently ignored. assert.match(header, /model_overrides/); }); }); + +describe('bug #4270 — Codex adapter forwards resolved spawn routing', () => { + test('forwards model and reasoning_effort when spawn_agent advertises each field', () => { + const header = getCodexSkillAdapterHeader('gsd-plan-phase'); + + assert.match(header, /Task\(model="\{resolved_model\}"\).*pass `model="\{resolved_model\}"`/s); + assert.match(header, /This is how `model_profile`.*including `adaptive`.*reaches the child agent/s); + assert.match(header, /query resolve-model --pick effort/); + assert.match(header, /unified `effort`.*spawn argument\s+`reasoning_effort`/s); + }); + + test('keeps inheritance fallback for absent fields and sentinel values', () => { + const header = getCodexSkillAdapterHeader('gsd-plan-phase'); + + assert.match(header, /Omit `model` only when.*does not advertise `model`.*empty.*`"inherit"`/s); + assert.match(header, /Detect optional fields independently/s); + assert.doesNotMatch(header, /spawn_agent` has no inline `model` parameter/); + }); +}); }); }