fix(planning): make auto model defaults runtime-aware; surface empty harness completions distinctly - #108
Merged
Merged
Conversation
The planning pipeline resolved its default model from env keys alone via
_default_planning_model(), ignoring the caller's resolved runtime. A caller
that pinned ai_provider="codex" in an OpenRouter-only environment therefore
had the codex CLI spawned with an "openrouter/..." model its OpenAI backend
cannot resolve — completing in ~1s with a null message.
Thread the resolved runtime into _default_planning_model() and delegate to
the already-runtime-aware resolve_runtime_models() cascade for the high-tier
pm role. The auto default is now gated on runtime:
- codex -> a codex-native model (never openrouter/...-prefixed)
- open_code -> the OpenRouter auto default (OpenRouter-only env) or the
open_code base default otherwise
- claude_code -> the "sonnet" historical default
Explicit args and deployer env (SWE_DEFAULT_MODEL / AI_MODEL / HARNESS_MODEL,
SWE_MODEL_HIGH) still win verbatim — only the auto default became
runtime-aware. Omitting the runtime arg preserves the prior env-only behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…der+model An empty harness completion (no parsed object, no text — the signature of a provider/model mismatch or bad auth) was collapsed into the generic "failed to produce a valid <artifact>" message, indistinguishable from a genuine schema-quality failure. Two distinct root causes, one opaque error. Add EmptyHarnessCompletionError and check_empty_harness_completion(), and wire them into the PM, architect, tech-lead and sprint-planner reasoners between the fatal-error check and the parsed-None check. The empty case now raises an error naming the provider and model; the generic schema-invalid message is kept only for non-empty-but-unparseable output and also gains provider+model context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n error Matrix over (env: openrouter-only / anthropic / SWE_DEFAULT_MODEL) x (runtime: open_code / codex / claude_code) asserting the resolved planning default, including that codex never yields an openrouter/-prefixed id. Plus unit and reasoner-level tests that an empty harness completion raises the distinct EmptyHarnessCompletionError naming provider and model, separate from the schema-invalid message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e table The matrix cell for "explicit open_code runtime, non-OpenRouter env" pinned a hardcoded literal (openrouter/minimax/minimax-m2.5) copied out of _RUNTIME_BASE_MODELS. That copy went stale as soon as the default model was rolled on main: _RUNTIME_BASE_MODELS["open_code"] is now _OPENROUTER_AUTO_DEFAULT_MODEL, so the assertion fails against a merge with current main even though the resolver is behaving correctly. Derive the expectation from _RUNTIME_BASE_MODELS instead of duplicating it, so the cell can never drift again when the default model is rolled next. Sourcing the expectation from the table would make the cell vacuous on its own, so add the behavioral assertion it was really there to make: pinning a non-Claude runtime (open_code or codex) must not resolve to the "sonnet" Claude alias. That is what the old env-only cascade returned whenever SWE_DEFAULT_RUNTIME was set to anything at all, and it holds regardless of which model id each runtime happens to default to. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
check_empty_harness_completion() raised EmptyHarnessCompletionError for any result with no parsed object and no raw text. The SDK's terminal schema-validation path has exactly that shape: it returns result=None with failure_type=SCHEMA. That happens when an agent *did* produce output which simply failed to validate — e.g. it wrote a malformed prd.json through the Write tool and emitted no closing prose. Labeling that "empty completion — check provider auth/model compatibility" points the operator at credentials and model ids when the real problem is the artifact's contents. Gate the check on failure_type: a schema classification is now a no-op, so the caller's existing schema-invalid message (which already names provider+model) propagates instead. Every other classification — none/crash/timeout/api_error/ no_output — still raises, as does a result with no failure_type attribute at all, so older SDKs keep the current behavior. FailureType is not re-exported from any public agentfield module, so rather than importing a private symbol the comparison is on the token. The enum subclasses str, so this works whether the attribute arrives as an enum member or a plain string. Also switch the parsed check from truthiness to `is not None`: a valid but falsy parsed object (an empty issue list, a model comparing false) is still a completion and must not be reported as an empty one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The docstring said omitting the runtime argument "keeps the historical env-only
behavior". That is not true in every configuration. The old cascade consulted
_openrouter_only_env(), which returns False as soon as SWE_DEFAULT_RUNTIME is
set to anything — so a deployer who pinned a runtime and set no model env var
got "sonnet" regardless of which runtime they pinned. Resolving the runtime and
delegating to resolve_runtime_models() now yields that runtime's own base
default instead:
SWE_DEFAULT_RUNTIME=open_code, no model env -> was sonnet, now open_code's base
SWE_DEFAULT_RUNTIME=codex, no model env -> was sonnet, now the codex base
for the active auth mode
That is the point of the change — pinning a runtime and silently receiving a
Claude planning model for it was the bug — but it is a behavior change and the
docstring should not claim otherwise. Spell out which configurations move and
which are genuinely untouched (no SWE_DEFAULT_RUNTIME, claude_code, an invalid
value, and anything setting a model env var).
Documentation only; no behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Incident
A caller invoked
swe-planner.planwithai_provider="codex"explicitly and nomodel overrides, in an environment where only
OPENROUTER_API_KEYwas set (noAnthropic/OpenAI keys, no
SWE_DEFAULT_*vars). Two independent failurescollapsed into one opaque error:
Cross-runtime model-id leakage. The planning-model default cascade
(
_default_planning_model()) resolved from env keys alone, ignoring thecaller's pinned runtime. With only an OpenRouter key present it returned the
OpenRouter auto-default model id — so the codex CLI was spawned with an
openrouter/…-prefixed model its OpenAI backend cannot resolve. Everyattempt completed in ~1s with a null/empty message.
Opaque empty completions. The pipeline surfaced only the generic
"Product manager failed to produce a valid PRD" — indistinguishable from a
genuine schema-quality failure (a weak model emitting unparseable output).
One message, two very different root causes.
What changed
Runtime-aware auto default (
fix(planning)) —_default_planning_model()now takes the resolved runtime and delegates to the already-runtime-aware
resolve_runtime_models()cascade for the high-tierpmrole.plan()threadsthe resolved
ai_providerinto it. The auto default is gated on runtime:codex→ a codex-native model (never anopenrouter/…-prefixed id)open_code→ the OpenRouter auto default (OpenRouter-only env) or theopen_codebase default otherwiseclaude_code→ the historicalsonnetdefaultExplicitly passed models and deployer env (
SWE_DEFAULT_MODEL/AI_MODEL/HARNESS_MODEL,SWE_MODEL_HIGH) still win verbatim — only the autodefault became runtime-aware.
Distinct empty-completion error (
fix(runtime)) — newEmptyHarnessCompletionError+check_empty_harness_completion(), wired intothe PM, architect, tech-lead and sprint-planner reasoners between the
fatal-error check and the
parsed is Nonecheck. An empty completion (no parsedobject and no text — the signature of a provider/model mismatch or bad auth)
now raises an error naming the provider and model, e.g.:
The generic "failed to produce a valid …" message is kept only for genuinely
non-empty-but-unparseable output, and now also includes provider+model context.
The check is gated on
failure_type. The SDK's terminal schema-validationpath returns
result=Nonewithfailure_type=SCHEMA, which has exactly theempty-completion shape (no parsed object, no text) but a completely different
cause — the agent did produce output, e.g. a malformed
prd.jsonwritten viathe Write tool with no closing prose, which simply failed to validate. Telling
that operator to "check provider auth/model compatibility" points at the wrong
thing, so a
schemaclassification is a no-op and the schema-invalid messagepropagates. Every other classification (
none/crash/timeout/api_error/no_output) still raises, and results with nofailure_typeattribute at allkeep the previous behavior, so older SDKs are unaffected. The comparison is on
the token rather than an imported symbol because
FailureTypeis notre-exported from any public
agentfieldmodule.Compatibility
This changes behavior for deployments that pin a non-Claude runtime and set no
model env var. The old cascade consulted
_openrouter_only_env(), whichreturns
Falseas soon asSWE_DEFAULT_RUNTIMEis set to anything — so thosedeployments fell through to
sonnetregardless of which runtime they pinned:SWE_DEFAULT_MODEL/AI_MODEL/HARNESS_MODEL/SWE_MODEL_HIGH)SWE_DEFAULT_RUNTIME=open_codesonnetopen_codebase defaultSWE_DEFAULT_RUNTIME=codex,OPENAI_API_KEYsetsonnetgpt-5.3-codexSWE_DEFAULT_RUNTIME=codex, noOPENAI_API_KEYsonnetgpt-5.5SWE_DEFAULT_RUNTIME=opencode(alias)sonnetopen_codebase defaultEverything else resolves exactly as before: no
SWE_DEFAULT_RUNTIME(both theOpenRouter-only auto-selection branch and the Claude branch),
SWE_DEFAULT_RUNTIME=claude_code, an invalidSWE_DEFAULT_RUNTIME, and anyconfiguration that sets a model env var.
SWE_DEFAULT_RUNTIME=open_codewith only anANTHROPIC_API_KEYpreviouslygot
sonnetfor planning, which worked because the opencode proxy could serveit. Those deployments will now get
openrouter/deepseek/deepseek-v4-flash-0731, which requires anOPENROUTER_API_KEY. This is intentional — it makes the planning modelsconsistent with the coding loop, which already resolved through
resolve_runtime_models()and was therefore already using the OpenRouterdefault in the same environment. But it is a real change: such a deployment must
add an
OPENROUTER_API_KEY, or pin planning explicitly viaSWE_MODEL_HIGH/SWE_DEFAULT_MODEL.Validation Contract
codex→ auto default is NOTopenrouter/-prefixed (codex-native)test_default_planning_model_matrix[openrouter_only-codex],test_codex_default_is_never_openrouter_prefixedopen_code→ OpenRouter auto default preservedtest_default_planning_model_matrix[openrouter_only-open_code]claude_code→sonnetdefault preservedtest_default_planning_model_matrix[*-claude_code]sonnetClaude aliastest_non_claude_runtime_default_is_not_the_claude_aliasopen_coderuntime resolvesopen_code's own base defaulttest_default_planning_model_matrix[anthropic-open_code]SWE_DEFAULT_MODEL/ explicit arg wins in all casestest_default_planning_model_matrix[swe_default_model-*], existingtest_plan_explicit_args_override_env,test_plan_swe_default_model_overrides_openrouter_autoTestCheckEmptyHarnessCompletion::*,test_run_product_manager_empty_completion_surfaces_provider_and_modelfailure_type=schemahas the empty-completion shape but does NOT raise itTestCheckEmptyHarnessCompletion::test_schema_failure_is_noop(SDK enum, plain string, upper-case, andstr(enum)spellings)failure_typestill raisesTestCheckEmptyHarnessCompletion::test_non_schema_failure_types_still_raisefailure_typeattribute keeps the previous behaviorTestCheckEmptyHarnessCompletion::test_result_without_failure_type_keeps_previous_behaviorTestCheckEmptyHarnessCompletion::test_falsy_but_present_parsed_output_is_noopAlso covered: runtime-alias normalization, the omitted-runtime env fallback, and
that non-empty-but-unparseable output is a no-op for the empty-completion check
(so the generic schema-invalid path still fires).
The
open_codebase-default expectation is read off_RUNTIME_BASE_MODELSrather than duplicated as a literal — the literal had already gone stale against
main's default-model roll, so the assertion would have failed on merge while the
resolver was behaving correctly.
Test results
Both runs on Python 3.12 with
AGENTFIELD_SERVERset, matching CI:make check(full pytest suite +compileall) — 1168 passed,1 skipped.
main:make check— 1181 passed, 1 skipped. This is what CIevaluates for a PR, and it is the run that catches the stale-literal drift.
ruff checkon the changed files reports the same findings as before thechange (no new ones); the repo has no ruff gate in CI.
go/files.🤖 Generated with Claude Code