feat(config): per-role model pin, and resolve every role at init - #35
Open
AIL-Texas-Bot wants to merge 2 commits into
Open
feat(config): per-role model pin, and resolve every role at init#35AIL-Texas-Bot wants to merge 2 commits into
AIL-Texas-Bot wants to merge 2 commits into
Conversation
`zenith init` read ambient ZENITH_*_PROVIDER when deciding what a lane would run, but never wrote those vars: ProviderSelection.env() emits a role provider only when it differs from the worker, and none of them are in RUNTIME_ENV_FORWARD_ALLOWLIST. The host agent is normally launched later, from a different shell, so init's view and the workspace's view could disagree — a diagnostic naming a hermes validator whose written config said claude, silence in the mirror case, and an init that failed on an ambient provider typo it would never have used. Init now resolves each role once (flag, then ambient var, then inherit down the chain) and writes the resolved provider and ACP command, so the runtime `discover() + for_role()` reproduces what init reported. The worker is deliberately exempt from the ambient step: it is the only role with a default of its own, and ZENITH_WORKER_PROVIDER has always been written unconditionally. An ACP command is taken from the environment only for a lane whose provider also came from the environment. Splitting a stale exported pair would pin one provider's binary to another provider's dispatch — no sandbox flags, no provider env, and a claude-only ACP mode id sent to codex. Also: assets are installed for every resolved role, not just the ones ProviderSelection knows about; a lane whose command does not run its provider's binary is warned about (comparing the executable, so an absolute path or extra arguments do not trip it); the summary reports resolved names instead of the flag-only view; and discover()'s ValueError is reported as a usage error rather than a traceback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PwnPYNttumenbdFpPkvHWa
Adds ZENITH_{WORKER,VALIDATOR,TERMINAL_REVIEWER}_MODEL and matching
--*-model flags on `zenith init`, so a mission can run its workers on one
model and its validation gate on another. codex receives the pin as
`-c model="..."`; claude-agent-acp has no model flag, so its pin travels
as ANTHROPIC_MODEL in the subprocess env.
A pin never crosses a provider boundary. Reasoning efforts are
provider-neutral vocabulary and inherit freely down the role chain, but a
codex worker's "gpt-5.5" handed to a claude validator would become
ANTHROPIC_MODEL="gpt-5.5" and break every session on that lane, so
_inherited_model walks the chain skipping links from other providers.
For the same reason the env vars are deliberately NOT in
RUNTIME_ENV_FORWARD_ALLOWLIST: an ambient pin arrives with no record of
which provider it was chosen for, so baking it into a workspace lands a
leftover codex pin on whatever provider that workspace runs. Pins enter a
workspace only through the flags, which are checked against the provider
resolved for that lane. An ambient pin still reaches a server launched
from the same shell; init just does not make it durable. An invalid one
fails init unless a flag replaces it.
Model ids are open-ended (provider aliases, pinned ids, Bedrock/Vertex
ARNs), so they cannot be checked against an allowlist the way efforts
are. They do reach a shell command line for codex, so the character set
is restricted with fullmatch to what real model identifiers use — no
quotes, spaces, or shell operators, and no trailing newline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PwnPYNttumenbdFpPkvHWa
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a per-role model pin, and fixes the init-time role resolution it exposed.
fix(cli): resolve every role at init and write what was resolvedzenith initread ambientZENITH_*_PROVIDERwhen deciding what a lane would run, but never wrote those vars:ProviderSelection.env()emits a role provider only when it differs from the worker, and none of them are inRUNTIME_ENV_FORWARD_ALLOWLIST. The host agent is normally launched later, from a different shell, so init's view and the workspace's view could disagree — a diagnostic naming a hermes validator whose written config said claude, silence in the mirror case, and an init that failed on an ambient provider typo it would never have used.Init now resolves each role once (flag, then ambient var, then inherit down the chain) and writes the resolved provider and ACP command, so runtime
discover() + for_role()reproduces what init reported. The worker is deliberately exempt from the ambient step: it is the only role with a default of its own, andZENITH_WORKER_PROVIDERhas always been written unconditionally.An ACP command is taken from the environment only for a lane whose provider also came from the environment. Splitting a stale exported pair would pin one provider's binary to another provider's dispatch — no sandbox flags, no provider env, and a claude-only ACP mode id sent to codex.
Also: assets are installed for every resolved role, not just the ones
ProviderSelectionknows about; a lane whose command does not run its provider's binary is warned about (comparing the executable, so an absolute path or extra arguments do not trip it); the summary reports resolved names; anddiscover()'sValueErroris reported as a usage error rather than a traceback.feat(config): per-role model pinZENITH_{WORKER,VALIDATOR,TERMINAL_REVIEWER}_MODELplus matching--*-modelflags, so a mission can run workers on one model and the validation gate on another. codex receives the pin as-c model="..."; claude-agent-acp has no model flag, so its pin travels asANTHROPIC_MODELin the subprocess env.A pin never crosses a provider boundary. Reasoning efforts are provider-neutral vocabulary and inherit freely down the role chain, but a codex worker's
gpt-5.5handed to a claude validator would becomeANTHROPIC_MODEL="gpt-5.5"and break every session on that lane, so_inherited_modelwalks the chain skipping links from other providers.For the same reason the env vars are deliberately not in
RUNTIME_ENV_FORWARD_ALLOWLIST: an ambient pin arrives with no record of which provider it was chosen for, so baking it into a workspace lands a leftover codex pin on whatever provider that workspace runs. Pins enter a workspace only through the flags, checked against the provider resolved for that lane. An ambient pin still reaches a server launched from the same shell; init just does not make it durable.Model ids are open-ended (provider aliases, pinned ids, Bedrock/Vertex ARNs), so they cannot be checked against an allowlist the way efforts are. They do reach a shell command line for codex, so the character set is restricted with
fullmatchto what real model identifiers use — no quotes, spaces, or shell operators, and no trailing newline.Overlap with open PRs
Flagging rather than resolving — this branch is based on current
mainand does not conflict today, but sequencing matters:fix(cli): wire --terminal-reviewer-provider/acp-command through to config) covers the same terminal-reviewer staging gap from the other direction. Whichever lands second will need a rebase; happy to be the one that rebases.fix(acp): route codex -c overrides through CODEX_CONFIG for npm adapter) changes how codex-coverrides are delivered, which is the same channel this PR appends-c model="..."to. If fix(acp): route codex -c overrides through CODEX_CONFIG for npm adapter #31 lands first, the model pin should route throughCODEX_CONFIGthe same way.Known gaps, deliberately not addressed here
ANTHROPIC_MODELstays in the forward allowlist, so an unpinned claude lane still runs on an ambient value if one is exported. That is the documented way to pin claude globally; the note onHarnessConfig.worker_modelnow says so instead of claiming a provider default.--validator-model ""is a usage error, and a same-provider lane always inherits the pin above it.Checklist
uv run ruff check .passesuv run mypy srcpassesuv run pytest -qpasses (266 tests; live-agent smoke tests excluded)🤖 Generated with Claude Code
https://claude.ai/code/session_01PwnPYNttumenbdFpPkvHWa