fix(#2256): pass per-agent model overrides through Codex/OpenCode transport (#2628)

The Codex and OpenCode install paths read `model_overrides` only from
`~/.gsd/defaults.json` (global). A per-project override set in
`.planning/config.json` — the reporter's exact setup for
`gsd-codebase-mapper` — was silently dropped, so the child agent inherited
the runtime's default model regardless of `model_overrides`.

Neither runtime has an inline `model` parameter on its spawn API
(Codex `spawn_agent(agent_type, message)`, OpenCode `task(description,
prompt, subagent_type, task_id, command)`), so the per-agent model must
reach the child via the static config GSD writes at install time. That
config was being populated from the wrong source.

Fix: add `readGsdEffectiveModelOverrides(targetDir)` which merges
`~/.gsd/defaults.json` with per-project `.planning/config.json`, with
per-project keys winning on conflict. Both install sites now call it and
walk up from the install root to locate `.planning/` — matching the
precedence `readGsdRuntimeProfileResolver` already uses for #2517.

Also update the Codex Task()->spawn_agent mapping block so it no longer
says "omit" without context: it now documents that per-agent overrides
are embedded in the agent TOML and notes the restriction that Codex
only permits `spawn_agent` when the user explicitly requested sub-agents
(do the work inline otherwise).

Regression tests (`tests/bug-2256-model-overrides-transport.test.cjs`)
cover: global-only, project-only, project-wins-on-conflict, walking up
from a nested `targetDir`, Codex TOML `model =` emission, and OpenCode
frontmatter `model:` emission.

Closes #2256

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-23 11:58:06 -04:00
committed by GitHub
parent bdba40cc3d
commit 5a8a6fb511
3 changed files with 242 additions and 5 deletions

View File

@@ -634,6 +634,63 @@ function readGsdGlobalModelOverrides() {
}
}
/**
* Effective per-agent model_overrides for the Codex / OpenCode install paths.
*
* Merges `~/.gsd/defaults.json` (global) with per-project
* `<project>/.planning/config.json`. Per-project keys win on conflict so a
* user can tune a single agent's model in one repo without re-setting the
* global defaults for every other repo. Non-conflicting keys from both
* sources are preserved.
*
* This is the fix for #2256: both adapters previously read only the global
* file, so a per-project `model_overrides` (the common case the reporter
* described — a per-project override for `gsd-codebase-mapper` in
* `.planning/config.json`) was silently dropped and child agents inherited
* the session default.
*
* `targetDir` is the consuming runtime's install root (e.g. `~/.codex` for
* a global install, or `<project>/.codex` for a local install). We walk up
* from there looking for `.planning/` so both cases resolve the correct
* project root. When `targetDir` is null/undefined only the global file is
* consulted (matches prior behavior for code paths that have no project
* context).
*
* Returns a plain `{ agentName: modelId }` object, or `null` when neither
* source defines `model_overrides`.
*/
function readGsdEffectiveModelOverrides(targetDir = null) {
const global = readGsdGlobalModelOverrides();
let projectOverrides = null;
if (targetDir) {
let probeDir = path.resolve(targetDir);
for (let depth = 0; depth < 8; depth += 1) {
const candidate = path.join(probeDir, '.planning', 'config.json');
if (fs.existsSync(candidate)) {
try {
const parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
if (parsed && typeof parsed === 'object' && parsed.model_overrides
&& typeof parsed.model_overrides === 'object') {
projectOverrides = parsed.model_overrides;
}
} catch {
// Malformed config.json — fall back to global; readGsdRuntimeProfileResolver
// surfaces a parse warning via _readGsdConfigFile already.
}
break;
}
const parent = path.dirname(probeDir);
if (parent === probeDir) break;
probeDir = parent;
}
}
if (!global && !projectOverrides) return null;
// Per-project wins on conflict; preserve non-conflicting global keys.
return { ...(global || {}), ...(projectOverrides || {}) };
}
/**
* #2517 — Read a single GSD config file (defaults.json or per-project
* config.json) into a plain object, returning null on missing/empty files
@@ -1852,9 +1909,17 @@ GSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collabo
Direct mapping:
- \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
- \`Task(model="...")\` → omit (Codex uses per-role config, not inline model selection)
- \`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.
- \`fork_context: false\` by default — GSD agents load their own context via \`<files_to_read>\` blocks
Spawn restriction:
- Codex restricts \`spawn_agent\` to cases where the user has explicitly
requested sub-agents. When automatic spawning is not permitted, do the
work inline in the current agent rather than attempting to force a spawn.
Parallel fan-out:
- Spawn multiple agents → collect agent IDs → \`wait(ids)\` for all to complete
@@ -3182,12 +3247,15 @@ function installCodexConfig(targetDir, agentsSrc) {
agents.push({ name, description: toSingleLine(description) });
// Pass model overrides from ~/.gsd/defaults.json so Codex TOML files
// Pass model overrides from both per-project `.planning/config.json` and
// `~/.gsd/defaults.json` (project wins on conflict) so Codex TOML files
// embed the configured model — Codex cannot receive model inline (#2256).
// Previously only the global file was read, which silently dropped the
// per-project override the reporter had set for gsd-codebase-mapper.
// #2517 — also pass the runtime-aware tier resolver so profile tiers can
// resolve to Codex-native model IDs + reasoning_effort when `runtime: "codex"`
// is set in defaults.json.
const modelOverrides = readGsdGlobalModelOverrides();
const modelOverrides = readGsdEffectiveModelOverrides(targetDir);
// Pass `targetDir` so per-project .planning/config.json wins over global
// ~/.gsd/defaults.json — without this, the PR's headline claim that
// setting runtime in the project config reaches the Codex emit path is
@@ -5893,9 +5961,13 @@ function install(isGlobal, runtime = 'claude') {
content = processAttribution(content, getCommitAttribution(runtime));
// Convert frontmatter for runtime compatibility (agents need different handling)
if (isOpencode) {
// Resolve per-agent model override from ~/.gsd/defaults.json (#2256)
// Resolve per-agent model override from BOTH per-project
// `.planning/config.json` and `~/.gsd/defaults.json`, with
// per-project winning on conflict (#2256). Without the per-project
// probe, an override set in `.planning/config.json` was silently
// ignored and the child inherited OpenCode's default model.
const _ocAgentName = entry.name.replace(/\.md$/, '');
const _ocModelOverrides = readGsdGlobalModelOverrides();
const _ocModelOverrides = readGsdEffectiveModelOverrides(targetDir);
const _ocModelOverride = _ocModelOverrides?.[_ocAgentName] || null;
content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
} else if (isKilo) {
@@ -7071,6 +7143,7 @@ if (process.env.GSD_TEST_MODE) {
mergeCodexConfig,
installCodexConfig,
readGsdRuntimeProfileResolver,
readGsdEffectiveModelOverrides,
install,
uninstall,
convertClaudeCommandToCodexSkill,