Skip to content

feat(stella-core): mid-turn model fallback on retries-exhausted — re-resolve through the router, repair the transcript, continue the turn - #2769

Merged
macanderson merged 1 commit into
mainfrom
feat/2679-midturn-model-fallback
Aug 10, 2026
Merged

feat(stella-core): mid-turn model fallback on retries-exhausted — re-resolve through the router, repair the transcript, continue the turn#2769
macanderson merged 1 commit into
mainfrom
feat/2679-midturn-model-fallback

Conversation

@macanderson

@macanderson macanderson commented Aug 10, 2026

Copy link
Copy Markdown
Owner

What & why

The engine held one provider for the whole turn, so a retry ladder exhausting against a wedged provider ended the turn Aborted { Failure } even when a healthy fallback was configured and resolvable (driver.rs's abort path) — every completed step's work stranded. This PR is the last ticket of the Phase 2 reliability chain: on retries-exhausted the engine now re-resolves through the router and continues the turn on the replacement.

Trigger classes. Everything that surfaces as the new ModelCallFailure::Exhausted — transport, 5xx, auth, and rate limiting that outlived #2744's parked recovery. ContextOverflow keeps its own rung (#2752); #2748's stream fallback still runs below, inside the attempt. The park composes rather than races: parking happens inside the ladder, the fallback only after the ladder gives up, and a soft stop typed during a park still wins at the next step boundary.

Router interplay (the #2734 seam, used as designed). The fallback is a re-resolution via the new stella_core::ports::FallbackResolver port, never a hardcoded list. drive_attempt_ladder feeds record_failure before settlement asks for a fallback, so Router::resolve already routes around the sick provider; a resolution landing back on the failed provider is a refusal and the turn aborts exactly as before. The bare CLI loops attach a router-backed SessionFallback (agent/engine.rs) at both run_turn engine sites — the session_router doc's declared destiny for #2679.

Transcript repair, deterministic. The failed call appended nothing, so the engine's own path is already well-paired; caller-supplied history with an orphaned tool_use is closed through the same close_open_tool_calls repair the cancel/soft-stop exits use (stub named for the swap, mirrored onto the event stream). Model-signed thinking blocks — the other thing a naive switch replays into a 400 — are structurally absent: CompletionMessage carries no reasoning blocks, so there is nothing to strip; the module doc records that argument.

Latch/bound. Engine::provider_override is a set-once cell and the set IS the latch: at most one swap per engine, ever — two sick providers cannot ping-pong. The override persists for the engine's remaining turns; the breaker's cooldown/half-open cycle is what routes fresh engines back to a recovered primary.

What consumers see. The ladder now withholds the terminal RetriesExhausted/Error pair (mirroring #2752's overflow arm) and settlement emits it only when no fallback fires — byte-identical to the old shape, pinned by a control test. A latched swap emits the existing AgentEvent::ProviderFallback (+ a retryable Error notice), so no new AgentEvent variant and no new consumer-ledger row (invariant #10 satisfied by reuse); the variant's doc and the generated docs/wire descriptions are updated (description-only diff, no shape change).

Accounting. Every burned attempt still bills through the per-attempt UsageIncomplete observer — witnessed. No timings ride ToolOutput.

God files. New logic is in sibling modules (driver/model_fallback.rs, the overflow_recovery.rs/settlement.rs pattern; tests in driver/tests/model_fallback.rs). agent.rs sat exactly at its ceiling, so the self-contained budget helpers moved to a new agent/budget.rs (move, not rewrite; re-exported so every caller path is unchanged) — agent.rs lands 40 lines under its inherited size.

Parity (invariant #8's cross-surface sibling). stella-parity gains the provider.midturn_fallback row claiming with_fallback_resolver (the entry-point sweep enforces this); CLI posture is ShippedUnwitnessed with the gap cited (#2733 for the attachment witness, #2765 for pipeline wiring), API NotApplicable for the same reason as provider.breaker_feedback. UNWITNESSED_BASELINE 4 → 5 — the declared-debt direction the ratchet exists to make visible, not an expedient.

Exemplar. The module shape, withheld-events discipline, latch bound, and test suite deliberately mirror driver/overflow_recovery.rs + driver/tests/context_overflow.rs (#2752), this repo's canonical recovery-rung shape.

Closes #2679
Refs #2733, #2734, #2744, #2748, #2752, #2765

The witness

  • This PR includes a witness test (fails on main, passes here)

crates/stella-core/src/driver/tests/model_fallback.rs:

  • exhausted_retries_swap_to_the_resolved_fallback_and_the_turn_completes — the headline IMPROVEMENT: mid-turn model fallback on retries-exhausted — repair orphaned tool_use, strip reasoning blocks, continue instead of aborting #2679 witness: terminally failing primary, healthy resolved fallback; on main the turn aborts, here it completes with the terminal channels silent and the swap announced.
  • a_sick_fallback_is_never_swapped_again — the bound: one ladder per provider, the resolver asked exactly once, second exhaustion terminal (no ping-pong).
  • the_transcript_handed_to_the_fallback_is_well_paired — pairing asserted on the transcript the replacement provider actually received, orphan stubbed with the swap's wording.
  • every_attempt_before_the_swap_is_billed_through_usage_incomplete — 1 attempt + 2 retries = 3 envelopes, then the rescue.
  • Controls: without_a_resolver_the_terminal_surfacing_is_unchanged (the relocated emission is byte-identical), a_resolution_back_onto_the_failed_provider_is_refused.

Flip evidence, artisanal (the ablation form, as in #2752 — the true on-main run cannot compile because the port doesn't exist there): with the one interception main lacks disabled (if false && self.attempt_provider_fallback(...) in settle_model_call_failure), the suite runs 2 passed; 4 failed — all four behavior witnesses FAIL (turn aborts, exactly main's behavior) while both abort-path controls stay green; restored, 6 passed; 0 failed.

The gate

  • cargo fmt --check (via make guards-fast, exit 0 — all toolchain-free guards incl. file-size, god-files, left-behind, typed-errors, module-reachability)
  • cargo clippy -p stella-core -p stella-cli --all-targets -- -D warnings — exit 0
  • cargo test -p stella-core -p stella-cli -p stella-parity — 1130 + 1693 + 9 (+ integration targets), 0 failed; cargo test -p stella-pipeline -p stella-serve -p stella-protocol — 0 failed
  • make wire-schema — regenerated, description-only diff (read above)
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p stella-core -p stella-cli -p stella-parity -p stella-protocol --document-private-items --keep-going — exit code 0, checked unpiped
  • Docs updated where behavior changed (module docs, event doc, parity row, session_router doc un-staled)
  • CLA signed
  • Closes #2679 appears both above and as a commit trailer

Nothing left behind

Ground-rule check

  • No I/O added to stella-core (the resolver is a port; the CLI impl owns the adapter build); no new deps
  • No new outbound network calls
  • No new cross-boundary types — ProviderFallback reused; docs/wire diff is description-only

Anything reviewers should know?

  • Terminal-event emission moved from drive_attempt_ladder into settle_model_call_failure (a settlement decision now that exhaustion has a recovery). The control test pins the observable shape; the one ordering change is that the model.request.failed bus signal now precedes the terminal pair — cross-channel (hook bus vs event stream), so no consumer can order them anyway.
  • ModelCallFailure::Fatal is renamed/retyped to Exhausted { message, attempt_reasons, retryable } — it is pub(crate), no external surface.
  • Sub-agent child engines deliberately do NOT inherit the resolver: a child's provider is the spec's explicit (possibly pinned cross-family) choice; the commented decision is at subagent.rs's engine assembly.
  • The prompt cache is necessarily cold on the replacement provider — the documented cost of finishing the turn at all.

Summary by Sourcery

Introduce mid-turn provider fallback in the engine so exhausted retry ladders can re-resolve through the router and continue the turn on a replacement provider, while preserving existing terminal behavior when no fallback is available.

New Features:

  • Add a FallbackResolver port and mid-turn provider fallback mechanism that re-resolves the worker role via the router after retries are exhausted and continues the turn on a replacement provider.
  • Wire bare CLI loops to attach a session-scoped router-backed SessionFallback so non-pipeline runs can benefit from mid-turn provider failover.

Bug Fixes:

  • Ensure exhausted retry ladders no longer abort turns when a healthy fallback provider is configured and resolvable, preventing work from being stranded mid-turn.

Enhancements:

  • Refine engine failure handling so retry exhaustion is settled in Engine::settle_model_call_failure, enabling either provider fallback or terminal surfacing with unchanged external shape.
  • Ensure transcript repair and billing behavior around mid-turn provider swaps keep tool-call pairing valid and preserve per-attempt UsageIncomplete accounting.
  • Add a one-swap-per-engine latch and route all dispatch/attribution through the active provider, avoiding provider ping-pong and mis-attribution after a fallback.
  • Clarify AgentEvent::ProviderFallback semantics to cover both resolution-time and mid-turn provider substitutions without changing the wire shape.
  • Keep sub-agent engines opt-out of fallback so their explicitly chosen providers surface failures back to the parent tool call.

Documentation:

  • Update wire schema docs, parity capabilities, and session_router documentation to describe the new mid-turn fallback behavior and its current CLI and API posture.

Tests:

  • Add driver/model_fallback tests that witness successful mid-turn fallback, enforce the one-swap bound, verify transcript repair and accounting, and confirm terminal behavior remains byte-identical when no fallback is attached.

Chores:

  • Extract session budget helper functions from agent.rs into a dedicated agent/budget.rs module to keep the god file under its size ceiling.
  • Extend stella-parity capabilities with a provider.midturn_fallback entry and bump the unwitnessed baseline to track the new unwitnessed CLI posture.

When a model call exhausts its retries against the active provider, the
engine now re-resolves the role through the new FallbackResolver port —
backed by the router whose circuit breaker the failing calls already
fed (#2673/#2734) — and continues the turn on the replacement instead
of aborting: transcript repaired through close_open_tool_calls,
terminal events withheld exactly as overflow recovery withholds its
own (#2680), the swap announced as ProviderFallback plus a retryable
Error notice, and at most one swap per engine (the set-once override
IS the latch, so two sick providers can never ping-pong). Every burned
attempt still bills through the per-attempt UsageIncomplete observer.

The bare CLI loops attach a router-backed SessionFallback beside the
session router at both run_turn engine sites; the budget helpers move
to agent/budget.rs to keep agent.rs under its god-file ceiling; the
parity matrix gains the provider.midturn_fallback row (pipeline
execute-stage wiring is #2765).

Closes #2679
Refs #2733, #2734, #2765
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Aug 10, 2026 11:33pm

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements mid-turn provider fallback when a model retry ladder exhausts by introducing a FallbackResolver port, a per-engine provider override latch, and router-based re-resolution, along with CLI wiring, transcript repair, and parity/docs updates; also refactors CLI budget helpers into a separate module.

Sequence diagram for mid-turn provider fallback on exhausted retries

sequenceDiagram
    participant Engine
    participant Provider as PrimaryProvider
    participant ProviderOutcomes
    participant FallbackResolver
    participant Router
    participant NewProvider
    participant Events as EventSender

    Engine->>Provider: drive_attempt_ladder(model_call)
    Provider-->>Engine: error (retry loop exhausts)
    Engine->>ProviderOutcomes: record_failure(active_provider.id)
    Engine-->>Engine: ModelCallFailure::Exhausted{message,attempt_reasons,retryable}
    Engine->>Events: MODEL_REQUEST_FAILED
    Engine->>Engine: attempt_provider_fallback(message,state,events)
    alt fallback_resolver_attached_and_latch_free
        Engine->>FallbackResolver: resolve_fallback(failed_provider_id)
        FallbackResolver->>Router: resolve(Role::Worker)
        Router-->>FallbackResolver: decision (healthy provider)
        FallbackResolver-->>Engine: ResolvedFallback{provider,reason}
        Engine-->>Engine: provider_override.set(resolved.provider)
        Engine->>Engine: close_open_tool_calls(FALLBACK_TOOL_RESULT,events)
        Engine->>Events: ProviderFallback{from,to,reason}
        Engine->>Events: Error{message: ...,retryable: true}
        Engine-->>Engine: state.step += 1
        Engine->>NewProvider: retried_model_call via active_provider()
    else no_fallback_or_resolution_back_to_failed_provider
        Engine->>Events: AgentEvent::RetriesExhausted{attempts,reasons,retryable}
        Engine->>Events: AgentEvent::Error{message,retryable}
        Engine-->>Engine: StepOutcome::Aborted{Failure}
    end
Loading

File-Level Changes

Change Details Files
Add mid-turn provider fallback infrastructure in the core engine, including router-based fallback resolution and a per-engine provider override latch, and wire it into retry exhaustion settlement.
  • Introduce the FallbackResolver port and ResolvedFallback struct to provide router-backed fallback providers without new I/O in stella-core.
  • Extend Engine with optional fallback resolver and a OnceLock-based provider_override, plus an active_provider accessor used for all dispatch and attribution paths.
  • Change drive_attempt_ladder to withhold terminal events, record breaker failures against the active provider, and return ModelCallFailure::Exhausted with detailed attempt reasons and retryable flag.
  • Update settle_model_call_failure to handle ModelCallFailure::Exhausted by emitting MODEL_REQUEST_FAILED, attempting a provider fallback via model_fallback, and only emitting the terminal RetiresExhausted/Error pair when no fallback fires.
crates/stella-core/src/ports.rs
crates/stella-core/src/driver.rs
crates/stella-core/src/driver/rate_limit.rs
crates/stella-core/src/driver/overflow_recovery.rs
crates/stella-core/src/driver/model_fallback.rs
Wire mid-turn fallback from the CLI via a session-scoped router-backed SessionFallback implementation, and ensure fallback is not inherited by subagent engines.
  • Update session_router docs and behavior to describe/router usage for fallback, and introduce SessionFallback implementing FallbackResolver using Router::resolve and discovered provider configs, caching a built adapter in a OnceLock.
  • Attach SessionFallback at both run_turn engine construction sites by calling Engine::with_fallback_resolver alongside with_provider_outcomes.
  • Ensure subagent child engines explicitly drop any fallback resolver and reset provider_override so child providers remain spec-driven and failures propagate to the parent.
crates/stella-cli/src/agent/engine.rs
crates/stella-core/src/subagent.rs
Repair transcripts and surface events consistently for fallback, while keeping external event shapes stable and documenting behavior in parity and wire docs.
  • Add FALLBACK_TOOL_RESULT and use close_open_tool_calls before swapping providers so the fallback sees a well-paired transcript; emit ProviderFallback plus a retryable Error notice when a swap latches.
  • Document ProviderFallback as covering both breaker-time and mid-turn substitutions and regenerate docs/wire schemas with description-only changes.
  • Update parity matrix with provider.midturn_fallback capability, bump UNWITNESSED_BASELINE, and mark CLI posture as ShippedUnwitnessed and API as NotApplicable.
  • Keep terminal RetiresExhausted/Error emission behavior byte-identical when no fallback is configured, as verified by control tests.
crates/stella-core/src/driver/model_fallback.rs
crates/stella-protocol/src/event.rs
docs/wire/agentevent.schema.json
docs/wire/serveframe.schema.json
crates/stella-parity/src/lib.rs
Add a focused test suite for mid-turn fallback behavior and refactor CLI budget helpers into a dedicated module to keep agent.rs below its size ceiling.
  • Introduce driver/tests/model_fallback.rs with scripted providers, a scripted FallbackResolver, and tests covering successful swap, ping-pong prevention, transcript pairing, usage accounting, and controls for no-resolver and self-fallback resolution.
  • Register the new test module in driver/tests.rs so it runs with the core test suite.
  • Move budget helper functions (build_budget_guard, remaining_budget, settle_reflection_budget) from agent.rs into a new agent/budget.rs module, and re-export them from agent.rs so external call sites remain unchanged.
crates/stella-core/src/driver/tests.rs
crates/stella-core/src/driver/tests/model_fallback.rs
crates/stella-cli/src/agent.rs
crates/stella-cli/src/agent/budget.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#2679 On exhausted retry ladders against a provider, perform a mid-turn fallback by re-resolving the model via the router and continuing the turn instead of aborting, with a cap of one swap per turn and preserving the existing abort path when no fallback is available.
#2679 Before issuing the fallback request, repair the transcript by closing any orphaned assistant tool_use blocks with synthetic tool_result stubs and ensuring the history is acceptable to the new provider (including handling of any problematic reasoning/thinking blocks).
#2679 Surface the fallback as a warning/event and update the session’s active provider/model so subsequent calls (and turns, where applicable) use the fallback provider instead of the dead primary.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@macanderson
macanderson enabled auto-merge (squash) August 10, 2026 23:40
@macanderson
macanderson disabled auto-merge August 10, 2026 23:42
@macanderson
macanderson merged commit 2365c4a into main Aug 10, 2026
16 checks passed
@macanderson
macanderson deleted the feat/2679-midturn-model-fallback branch August 10, 2026 23:43
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.

IMPROVEMENT: mid-turn model fallback on retries-exhausted — repair orphaned tool_use, strip reasoning blocks, continue instead of aborting

1 participant