fix(#4270): forward Codex spawn model routing (#4281)

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Michel Moreira
2026-09-05 15:20:26 -03:00
committed by GitHub
parent 7ff196c505
commit 86b745b48b
8 changed files with 114 additions and 45 deletions

View File

@@ -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)

View File

@@ -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 <subagent_type> --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 \`<required_reading>\` 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

View File

@@ -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.<selector>` | 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 <runtime>` 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 <runtime>` 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 <runtime>` 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.<runtime>.<tier>` | 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):

View File

@@ -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.

View File

@@ -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/<agent>.toml`, so setting `model_profile` has no effect on Codex.
GSD deliberately writes no profile-resolved `model` line into
`~/.codex/agents/<agent>.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:

View File

@@ -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 <subagent_type> --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 \`<required_reading>\` 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

View File

@@ -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 <subagent_type> --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');

View File

@@ -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 <subagent_type> --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/);
});
});
});
}