Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion swe_af/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1449,8 +1449,13 @@ async def plan(
``_default_runtime``). Any explicitly passed value always wins.
"""
# Resolve provider/model defaults from the environment (see docstring).
# The model default is resolved *for the chosen runtime* so it can never
# hand a provider-prefixed id (e.g. an ``openrouter/…`` model) to a runtime
# whose CLI can't consume it — the cross-runtime leak that caused silent
# ~1s empty completions when a caller pinned ``codex`` under OpenRouter-only
# env. Explicit ``ai_provider`` still wins; only the auto default is gated.
ai_provider = ai_provider or _default_runtime()
default_model = _default_planning_model()
default_model = _default_planning_model(ai_provider)
pm_model = pm_model or default_model
architect_model = architect_model or default_model
tech_lead_model = tech_lead_model or default_model
Expand Down
78 changes: 78 additions & 0 deletions swe_af/execution/fatal_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ def __init__(self, message: str) -> None:
self.original_message = message


class EmptyHarnessCompletionError(RuntimeError):
"""Raised when a harness call completes but produces no output at all.

Distinct from a *schema-invalid* completion (output present but unparseable
into the target schema). An empty completion — the harness returned with no
parsed object and no raw text, typically in ~1s — almost always means the
provider/model pairing is wrong: e.g. a model id prefixed for a *different*
runtime (an ``openrouter/…`` model handed to the codex CLI's OpenAI
backend, which doesn't know it) or missing/invalid auth for the selected
provider. Collapsing this into the generic "failed to produce a valid
<artifact>" message makes it indistinguishable from a genuine schema-quality
failure, so this error names the provider and model explicitly to point at
the real root cause.
"""

def __init__(self, *, role: str, provider: str, model: str, detail: str = "") -> None:
message = (
f"{role} harness returned an empty completion "
f"(provider={provider}, model={model}) — check provider "
f"auth/model compatibility"
)
if detail:
message = f"{message}: {detail}"
super().__init__(message)
self.role = role
self.provider = provider
self.model = model
self.original_message = detail


def is_fatal_error(error_message: str) -> bool:
"""Return True if *error_message* matches a known fatal API error pattern."""
if not error_message:
Expand Down Expand Up @@ -86,3 +116,51 @@ def check_fatal_harness_error(result) -> None:
msg = getattr(result, "error_message", "") or ""
if is_fatal_error(msg):
raise FatalHarnessError(msg)


def _harness_output_text(result) -> str:
"""Best-effort raw completion text from a HarnessResult-like object.

Reads ``result`` (the raw completion string) first, falling back to the
``text`` convenience property. Returns ``""`` when neither is populated.
"""
raw = getattr(result, "result", None)
if not raw:
raw = getattr(result, "text", None)
return raw or ""


def check_empty_harness_completion(
result, *, role: str, provider: str, model: str
) -> None:
"""Raise ``EmptyHarnessCompletionError`` when a harness produced no output.

Call *after* ``check_fatal_harness_error`` and *before* the caller's
``parsed is None`` schema-quality check. This fires only for the "empty
completion" shape — neither a parsed object nor any raw text — which is the
signature of a provider/model mismatch (a model id meant for a different
runtime, or bad auth) rather than a schema-quality problem. When raw text
*is* present but couldn't be parsed, this is a no-op and the caller's
generic schema-invalid error (which should also name provider+model) takes
over.

Parameters
----------
result:
A ``HarnessResult`` (or any object exposing ``parsed`` / ``result`` /
``text`` / ``error_message``).
role:
Human label for the failing reasoner (e.g. ``"PM"``, ``"Architect"``).
provider:
The harness provider the call used (e.g. ``"codex"``, ``"opencode"``).
model:
The model id the call was made with.
"""
if getattr(result, "parsed", None):
return
if _harness_output_text(result).strip():
return
detail = (getattr(result, "error_message", "") or "").strip()
raise EmptyHarnessCompletionError(
role=role, provider=provider, model=model, detail=detail
)
56 changes: 36 additions & 20 deletions swe_af/execution/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
model_validator,
)
from swe_af.hitl.ask_user import AskUserForm
from swe_af.runtime.providers import RUNTIME_VALUES, runtime_to_harness_provider
from swe_af.runtime.providers import (
RUNTIME_VALUES,
normalize_runtime_provider,
runtime_to_harness_provider,
)

# Global default for all agent max_turns. Change this one value to adjust everywhere.
DEFAULT_AGENT_MAX_TURNS: int = 150
Expand Down Expand Up @@ -709,32 +713,44 @@ def _tier_models_from_env() -> dict[str, str]:
return tiers


def _default_planning_model() -> str:
def _default_planning_model(runtime: str | None = None) -> str:
"""Model for the planning reasoners (the ``plan`` pipeline) when the caller
passes no model.
passes no model, resolved for the *given runtime*.

The planning reasoners take an explicit ``model`` argument rather than a
runtime ``models={}`` config, so the ``resolve_runtime_models`` cascade
doesn't apply to them. This mirrors that cascade for the planning path so an
OpenRouter-only deployment is zero-config. The planning reasoners are
high-tier roles (see ``ROLE_TO_TIER``), so ``SWE_MODEL_HIGH`` beats the
generic default-model env — the same relative precedence tier env vars have
in ``resolve_runtime_models``. Precedence, first match wins:
runtime ``models={}`` config, so the SDK never runs the
``resolve_runtime_models`` cascade for them. This delegates to that same
cascade for the high-tier ``pm`` role so the planning path picks a model
that is valid for ``runtime`` — critically, the auto default is
runtime-gated and never leaks a provider-prefixed id (e.g. an
``openrouter/…`` model) into a runtime whose CLI cannot consume it. That
cross-runtime leak was the root cause of silent ~1s empty completions when a
caller pinned ``codex`` in an OpenRouter-only environment.

``runtime`` is normalized (aliases like ``claude`` / ``opencode`` accepted).
When omitted, the runtime is resolved from the environment via
``_default_runtime`` so callers that don't yet know the runtime keep the
historical env-only behavior.

Precedence is inherited from ``resolve_runtime_models`` (highest first):

1. ``SWE_MODEL_HIGH`` (planning reasoners are high-tier)
2. deployer env (``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` → ``HARNESS_MODEL``)
3. the OpenRouter default when only an OpenRouter key is present
4. the Claude ``sonnet`` alias (historical default)
3. the runtime's own auto/base default:
- ``codex`` → a codex-native model (never ``openrouter/…``)
- ``open_code`` → the OpenRouter auto default (OpenRouter-only
env) or the ``open_code`` base default
- ``claude_code`` → the Claude ``sonnet`` alias (historical default)

Env / explicit values (layers 1–2) still win verbatim — the deployer owns
them — so only the auto default (layer 3) is made runtime-aware.
"""
high_model = _tier_models_from_env().get("high")
if high_model:
return high_model
env_model = _default_model_from_env()
if env_model:
return env_model
if _openrouter_only_env():
return _OPENROUTER_AUTO_DEFAULT_MODEL
return "sonnet"
resolved_runtime = normalize_runtime_provider(runtime) if runtime else _default_runtime()
return resolve_runtime_models(
runtime=resolved_runtime,
models=None,
field_names=["pm_model"],
)["pm_model"]


def _legacy_hint_for_model_key(key: str) -> str:
Expand Down
43 changes: 38 additions & 5 deletions swe_af/reasoners/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@

from pydantic import BaseModel

from swe_af.execution.fatal_error import check_fatal_harness_error
from swe_af.execution.fatal_error import (
check_empty_harness_completion,
check_fatal_harness_error,
)
from swe_af.execution.schemas import DEFAULT_AGENT_MAX_TURNS
from swe_af.reasoners.schemas import (
Architecture,
Expand Down Expand Up @@ -217,6 +220,12 @@ async def _invoke_pm(prior_user_responses: list[dict] | None) -> PRD | None:
cwd=repo_path,
)
check_fatal_harness_error(result)
# An empty completion here (no parsed output, no text) is a
# provider/model mismatch, not a schema-quality problem — surface it
# distinctly with provider+model instead of the generic PRD message.
check_empty_harness_completion(
result, role="PM", provider=provider, model=model
)
return result.parsed

initial_prior = list(prior_user_responses or [])
Expand All @@ -231,7 +240,13 @@ async def _invoke_pm(prior_user_responses: list[dict] | None) -> PRD | None:
)

if parsed is None:
raise RuntimeError("Product manager failed to produce a valid PRD")
# Reached only when the harness produced non-empty but unparseable
# output (empty completions are raised distinctly above). Name the
# provider+model so the failure is diagnosable.
raise RuntimeError(
f"Product manager failed to produce a valid PRD "
f"(provider={provider}, model={model})"
)

router.note("PM complete", tags=["pm", "complete"])
return parsed.model_dump()
Expand Down Expand Up @@ -407,8 +422,14 @@ async def run_architect(
cwd=repo_path,
)
check_fatal_harness_error(result)
check_empty_harness_completion(
result, role="Architect", provider=provider, model=model
)
if result.parsed is None:
raise RuntimeError("Architect failed to produce a valid architecture")
raise RuntimeError(
f"Architect failed to produce a valid architecture "
f"(provider={provider}, model={model})"
)

router.note("Architect complete", tags=["architect", "complete"])
return result.parsed.model_dump()
Expand Down Expand Up @@ -462,8 +483,14 @@ async def run_tech_lead(
cwd=repo_path,
)
check_fatal_harness_error(result)
check_empty_harness_completion(
result, role="Tech lead", provider=provider, model=model
)
if result.parsed is None:
raise RuntimeError("Tech lead failed to produce a valid review")
raise RuntimeError(
f"Tech lead failed to produce a valid review "
f"(provider={provider}, model={model})"
)

review = result.parsed.model_dump()
review_json_path = os.path.join(base, "plan", "review.json")
Expand Down Expand Up @@ -539,8 +566,14 @@ class SprintPlanOutput(BaseModel):
cwd=repo_path,
)
check_fatal_harness_error(result)
check_empty_harness_completion(
result, role="Sprint planner", provider=provider, model=model
)
if result.parsed is None:
raise RuntimeError("Sprint planner failed to produce valid issues")
raise RuntimeError(
f"Sprint planner failed to produce valid issues "
f"(provider={provider}, model={model})"
)

router.note("Sprint Planner complete", tags=["sprint_planner", "complete"])
return {
Expand Down
Loading
Loading