Skip to content

fix(planning): make auto model defaults runtime-aware; surface empty harness completions distinctly - #108

Merged
AbirAbbas merged 6 commits into
mainfrom
fix/model-cascade-runtime
Aug 10, 2026
Merged

fix(planning): make auto model defaults runtime-aware; surface empty harness completions distinctly#108
AbirAbbas merged 6 commits into
mainfrom
fix/model-cascade-runtime

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Incident

A caller invoked swe-planner.plan with ai_provider="codex" explicitly and no
model overrides, in an environment where only OPENROUTER_API_KEY was set (no
Anthropic/OpenAI keys, no SWE_DEFAULT_* vars). Two independent failures
collapsed into one opaque error:

  1. Cross-runtime model-id leakage. The planning-model default cascade
    (_default_planning_model()) resolved from env keys alone, ignoring the
    caller'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. Every
    attempt completed in ~1s with a null/empty message.

  2. 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-tier pm role. plan() threads
the resolved ai_provider into it. The auto default is gated on runtime:

  • codex → a codex-native model (never an openrouter/…-prefixed id)
  • open_code → the OpenRouter auto default (OpenRouter-only env) or the
    open_code base default otherwise
  • claude_code → the historical sonnet default

Explicitly passed models and deployer env (SWE_DEFAULT_MODEL / AI_MODEL /
HARNESS_MODEL, SWE_MODEL_HIGH) still win verbatim — only the auto
default became runtime-aware.

Distinct empty-completion error (fix(runtime)) — new
EmptyHarnessCompletionError + check_empty_harness_completion(), wired into
the PM, architect, tech-lead and sprint-planner reasoners between the
fatal-error check and the parsed is None check. An empty completion (no parsed
object and no text — the signature of a provider/model mismatch or bad auth)
now raises an error naming the provider and model, e.g.:

PM harness returned an empty completion (provider=codex,
model=openrouter/deepseek/deepseek-v4-flash) — check provider auth/model
compatibility

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-validation
path returns result=None with failure_type=SCHEMA, which has exactly the
empty-completion shape (no parsed object, no text) but a completely different
cause — the agent did produce output, e.g. a malformed prd.json written via
the 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 schema classification is a no-op and the schema-invalid message
propagates. Every other classification (none/crash/timeout/api_error/
no_output) still raises, and results with no failure_type attribute at all
keep the previous behavior, so older SDKs are unaffected. The comparison is on
the token rather than an imported symbol because FailureType is not
re-exported from any public agentfield module.

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(), which
returns False as soon as SWE_DEFAULT_RUNTIME is set to anything — so those
deployments fell through to sonnet regardless of which runtime they pinned:

Config (no SWE_DEFAULT_MODEL / AI_MODEL / HARNESS_MODEL / SWE_MODEL_HIGH) Before After
SWE_DEFAULT_RUNTIME=open_code sonnet open_code base default
SWE_DEFAULT_RUNTIME=codex, OPENAI_API_KEY set sonnet gpt-5.3-codex
SWE_DEFAULT_RUNTIME=codex, no OPENAI_API_KEY sonnet gpt-5.5
SWE_DEFAULT_RUNTIME=opencode (alias) sonnet open_code base default

Everything else resolves exactly as before: no SWE_DEFAULT_RUNTIME (both the
OpenRouter-only auto-selection branch and the Claude branch),
SWE_DEFAULT_RUNTIME=claude_code, an invalid SWE_DEFAULT_RUNTIME, and any
configuration that sets a model env var.

⚠️ Deployment note. The concrete case worth calling out:
SWE_DEFAULT_RUNTIME=open_code with only an ANTHROPIC_API_KEY previously
got sonnet for planning, which worked because the opencode proxy could serve
it. Those deployments will now get
openrouter/deepseek/deepseek-v4-flash-0731, which requires an
OPENROUTER_API_KEY
. This is intentional — it makes the planning models
consistent with the coding loop, which already resolved through
resolve_runtime_models() and was therefore already using the OpenRouter
default in the same environment. But it is a real change: such a deployment must
add an OPENROUTER_API_KEY, or pin planning explicitly via SWE_MODEL_HIGH /
SWE_DEFAULT_MODEL.

Validation Contract

Contract behavior Test
OpenRouter-only env + runtime codex → auto default is NOT openrouter/-prefixed (codex-native) test_default_planning_model_matrix[openrouter_only-codex], test_codex_default_is_never_openrouter_prefixed
OpenRouter-only env + runtime open_code → OpenRouter auto default preserved test_default_planning_model_matrix[openrouter_only-open_code]
runtime claude_codesonnet default preserved test_default_planning_model_matrix[*-claude_code]
Pinning a non-Claude runtime never falls back to the sonnet Claude alias test_non_claude_runtime_default_is_not_the_claude_alias
An explicit open_code runtime resolves open_code's own base default test_default_planning_model_matrix[anthropic-open_code]
SWE_DEFAULT_MODEL / explicit arg wins in all cases test_default_planning_model_matrix[swe_default_model-*], existing test_plan_explicit_args_override_env, test_plan_swe_default_model_overrides_openrouter_auto
Empty harness completion → error containing provider+model, distinct from schema-invalid message TestCheckEmptyHarnessCompletion::*, test_run_product_manager_empty_completion_surfaces_provider_and_model
failure_type=schema has the empty-completion shape but does NOT raise it TestCheckEmptyHarnessCompletion::test_schema_failure_is_noop (SDK enum, plain string, upper-case, and str(enum) spellings)
Every other failure_type still raises TestCheckEmptyHarnessCompletion::test_non_schema_failure_types_still_raise
A result with no failure_type attribute keeps the previous behavior TestCheckEmptyHarnessCompletion::test_result_without_failure_type_keeps_previous_behavior
A valid-but-falsy parsed object counts as a completion TestCheckEmptyHarnessCompletion::test_falsy_but_present_parsed_output_is_noop

Also 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_code base-default expectation is read off _RUNTIME_BASE_MODELS
rather 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_SERVER set, matching CI:

  • Branch head: make check (full pytest suite + compileall) — 1168 passed,
    1 skipped.
  • Merged with main: make check — 1181 passed, 1 skipped. This is what CI
    evaluates for a PR, and it is the run that catches the stale-literal drift.
  • ruff check on the changed files reports the same findings as before the
    change (no new ones); the repo has no ruff gate in CI.
  • Go CI job untouched — the diff contains no go/ files.

🤖 Generated with Claude Code

AbirAbbas and others added 6 commits July 22, 2026 15:11
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>
@AbirAbbas
AbirAbbas merged commit e974fee into main Aug 10, 2026
3 checks passed
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