Skip to content

feat(config): per-role model pin, and resolve every role at init - #35

Open
AIL-Texas-Bot wants to merge 2 commits into
Intelligent-Internet:mainfrom
AIL-Texas-Bot:feat/per-role-model-pin
Open

feat(config): per-role model pin, and resolve every role at init#35
AIL-Texas-Bot wants to merge 2 commits into
Intelligent-Internet:mainfrom
AIL-Texas-Bot:feat/per-role-model-pin

Conversation

@AIL-Texas-Bot

Copy link
Copy Markdown

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 resolved

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 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; and discover()'s ValueError is reported as a usage error rather than a traceback.

feat(config): per-role model pin

ZENITH_{WORKER,VALIDATOR,TERMINAL_REVIEWER}_MODEL plus matching --*-model flags, 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 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, 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 fullmatch to 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 main and does not conflict today, but sequencing matters:

Known gaps, deliberately not addressed here

  • ANTHROPIC_MODEL stays 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 on HarnessConfig.worker_model now says so instead of claiming a provider default.
  • There is no way to express "this lane is unpinned" — --validator-model "" is a usage error, and a same-provider lane always inherits the pin above it.

Checklist

  • uv run ruff check . passes
  • uv run mypy src passes
  • uv run pytest -q passes (266 tests; live-agent smoke tests excluded)
  • Tests added/updated for behavior changes

🤖 Generated with Claude Code

https://claude.ai/code/session_01PwnPYNttumenbdFpPkvHWa

AIL-Texas-Bot and others added 2 commits August 9, 2026 09:56
`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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant