fix(*): carry a live model switch to every provider holder - #282
fix(*): carry a live model switch to every provider holder#282arelchan wants to merge 5 commits into
Conversation
config.set key="model" built a fresh provider and then assigned loop.provider and loop.model. The loop is not the only holder: AgentLoop hands the provider it was built with to the subagent manager, to the context engine's LLM-backed segments (skill rewriter, skill gate, curator and its history trimmer) and to the memory consolidator, and each keeps its own reference. A switch that stopped at the loop left all of them calling the provider built at process start for the rest of the run. What that looks like in practice: switching away from an unusable credential fixes the main loop, while subagent spawns and the skill rewriter/gate keep failing to authenticate against the abandoned endpoint. The auth error is classified non-retryable, so each one fails on the first attempt and is swallowed by its caller's fallback, which is why this stayed invisible apart from a warning line. AgentLoop.set_provider now fans the new provider out to every holder, and the RPC handler calls it instead of assigning the two attributes. The context engine walks its builders and forwards to the ones implementing set_provider, so a purely textual segment needs no override. A pinned gate model and an explicit config.curator_model survive the switch; both follow the agent's model only when they were already following it. In-flight turns and subagents keep the provider they started with, so no single conversation spans two endpoints. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Review of #282 (panel)Reviewed by two independent agents on different model families, each reading the PR head ( The direction is right and the fan-out inside Severity labels: R1 - before-merge - correctnessWhere: Problem: Both docstrings state that in-flight turns and subagents keep the provider they started with, so no single conversation spans two endpoints. No mechanism provides that. Every LLM call site reads the instance attribute at call time, not a per-turn snapshot: The direction is also inverted: before this PR the subagent manager's reference never changed, so a running subagent genuinely could not span two endpoints. After it, it can. The sentence describes the property this change removes, and it is presented as a deliberate non-change. Failure scenario: Session A triggers Suggested fix: Either make the promise real -- snapshot at entry ( Verify: A test that starts a fake multi-iteration turn, calls R2 - before-merge - correctnessWhere: Problem: A model id and a credential are one pair. Failure scenario: Suggested fix: Make the pin and the provider move together or not at all. Smallest version: when a pin is present, leave that holder's provider alone. More thorough: resolve the pinned model with Verify: Instantiate R3 - before-merge - test-coverageWhere: Problem: The diff adds 11
So the suite catches "the dispatcher forgot a holder" and nothing else. Combined with the duck-typed fan-out at Failure scenario: Someone adds a provider-holding segment builder, or renames Suggested fix: Two additions, both cheap because the fixtures already exist. (1) Build a real engine through Verify: Re-run the three mutations above; each should now turn something red. R4 - describe - dead-branchWhere: Problem: Failure scenario: No runtime break introduced by this PR, but a reader of Suggested fix: Either delete Verify: R5 - describe - scopeWhere: PR title and description ("every provider holder", "the five subsystems"), plus the maintenance note at Problem: Two holders take the same Failure scenario: Not reachable today. Future: any hot switch added on the gateway side leaves heartbeat and Sentinel on the abandoned credential, with the same swallowed-warning signature. Suggested fix: Narrow "every provider holder" to "AgentLoop and the subsystems it builds" in the title/description, and make the note at Verify: N/A (wording and follow-up tracking). R6 - describe - verification-claimWhere: PR description, last paragraph of Problem: The claim that Failure scenario: N/A for the code. The risk is procedural: the next person to hit it starts from "known pre-existing failure on main" as an established fact. Suggested fix: Restate it as what was observed ("fails locally in a full-suite run, not reproducible on a clean worktree or in CI"), or drop the deselect and the paragraph. Verify: R7 - nit - commentsWhere: Problem: Two of these say something the code does not. Separately, and purely take-it-or-leave-it: Failure scenario: N/A (accuracy of prose that lands on main). Suggested fix: Fix the direction and the list at Verify: N/A. Checked and found fineSo they do not get re-litigated later:
|
Review of #282 found the fan-out landed but its promise did not. Both docstrings claimed a running turn or subagent keeps the provider it started with; nothing provided that. Every LLM call site reads the provider off self at call time, so before this the subagent manager's reference simply never changed -- the fan-out is what made a running subagent able to span two vendors, and that was documented as a deliberate non-change. Two mechanisms, because the two lifetimes differ. AgentLoop parks a switch that arrives mid-turn and adopts it at the next run_turn entry: one boundary covers the dozen self.provider reads plus the context engine and consolidator underneath them, where a snapshot would have to be threaded through each. A subagent is a detached task that outlives its turn, so the park cannot reach it; _run_subagent_inner reads the provider and model once before its iteration loop instead. Also from the review: - curator: drop the branch on config.curator_model. It is declared str with a non-empty default, so it is never falsy and the branch never ran; curator_model is always a pin, at construction too. - gate: stop describing a kept pin as safe. A pin is only a model id while the credential comes from the provider, so a pin naming a vendor the provider does not serve was already broken at boot. Fixing that pairing is a separate change. - context_engine.base: the concrete no-op exists because AgentLoop calls through the ABC unconditionally, not because an engine without LLM-backed segments exists. There is only one implementation. - main.py: the fan-out comment pointed the wrong way. All four receivers are above it, and the list below it names the one attribute not in the fan-out. Tests: the previous file only exercised the dispatcher, so replacing any receiver with pass left it green. It now builds a real AgentLoop and asserts the gate, rewriter, curator, curator assembler, trimmer, subagent manager and consolidator all moved; guards the attribute names the fan-out walks against a rename; and drives the real _run_subagent_inner across a switch. Each of those five mutations now fails something. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Panel review of the two commits above found the park did nothing in the one configuration it exists for. OriginPools gates USER and system origins on independent semaphores with no global cap (spine/scheduler.py), and the TUI defaults to one slot each, so a user turn and a cron turn run concurrently on one AgentLoop. With a bool: the shorter turn's finally cleared the flag under the longer one, and a correctly parked switch was adopted by an unrelated turn entering run_turn. Both land the switch mid-flight, which is what the park exists to prevent. Now a depth counter, with both ends gated on zero, and the last turn out adopts so a park cannot outlive the turns it waited on. The subagent snapshot moved from _run_subagent_inner to spawn. A spawn queues behind the concurrency gate and a sandbox boot before the inner method runs, and a switch landing in that window handed the task an endpoint the user chose after asking for it -- so "only spawns started after this call are affected" was not true of the window that matters. Three prose corrections, all cases of describing a property the code does not have: - "LiteLLM drops the shapes the new vendor rejects instead of failing" named the wrong mechanism. drop_params filters request kwargs, not message content. The silence comes from the provider turning a rejected request into finish_reason="error" content. - "curator_model is always a pin, at construction either" was false for an explicitly empty context.curator_model, which the constructor's own `or model` still follows. set_provider now re-derives with the constructor's expression instead of asserting. - "a dozen call sites" was eight. The park's relationship to the RPC guard is now stated: is_turn_active rejects a same-session switch first, the park covers what that cannot see, and a parked switch is on disk while the loop still reports the old model. Tests: the run_turn wrapper had no coverage at all -- deleting its finally left the suite green -- because the park test hand-set the flag and hand-called the adopt. It now drives the real run_turn: adopt on entry, slot released on return and on exception, and a second concurrent turn that must not unpark a switch held for the first. Plus a spawn-time snapshot test. Signature change to _run_subagent/_run_subagent_inner updated in the two suites that stub them. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Review found two docstrings of the kind this PR was already rejected for once. The park docstring said a mid-turn split "does not raise"; that holds for the chat_with_retry sites, but _llm_call_stream -- the path a TUI turn takes -- catches only TimeoutError, so there the rejection propagates. And the context-engine ABC justified its concrete no-op by the loop calling it unconditionally, which an abstract method would satisfy equally; what concrete buys is not forcing a future implementation to write an empty override. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The only mutation the review could not kill: moving the snapshot from spawn into _run_subagent_inner left the suite green, which is exactly the state the commit before it was written to fix. Neither existing test could see it -- one stubbed _run_subagent wholesale, so it proved spawn passes a pair but not when the pair is read; the other called _run_subagent_inner directly, bypassing spawn, the concurrency gate and the sandbox boot, so it proved the iteration loop does not re-read but not where the read happens. This drives the real _run_subagent with the gate held shut, switches the provider while the task sits in that window, then releases it and asserts which provider actually served the call. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Second round on the delta (
|
Summary
A live
/modelswitch rebuilt the provider but only reassignedloop.provider/loop.model.AgentLoop.__init__had already handed that provider to the subagent manager, the context engine's LLM-backed segments and the memory consolidator, and each kept its own reference. Switching away from a dead credential fixed the main loop while subagents and the skill rewriter/gate went on authenticating against the endpoint the user had just abandoned. Cron was a fourth victim: its runs failed with the same 401 and its history rendered the failures as blank rows.AgentLoop.set_providernow fans the pair out to every holder it built, and the context engine walks its builders duck-typed so a text-only segment is skipped rather than raising.In-flight work
Every LLM call site reads the provider off
selfat call time, so an unconditional swap relays one conversation across two vendors. How that surfaces depends on the path: thechat_with_retrysites turn a rejected request intofinish_reason="error"content, so the turn reports a failure with no sign that its endpoint moved, while_llm_call_stream-- the path a TUI turn takes -- catches onlyTimeoutErrorand lets the rejection propagate. Neither is a diagnosis the user can act on.Two mechanisms, because the two lifetimes differ. Both are superseded by #284, which makes the
model a property of the conversation and gets the same guarantee from the context copy that
asynciomakes at task creation -- if the two land together, the park described here exists onlybetween the two merges.
run_turnentry. One boundary covers eightself.providerreads inloop/main.pyplus the context engine and consolidator underneath them; a snapshot would have to be threaded through each. The park is a depth counter, not a flag:OriginPoolsgates USER and system origins on independent semaphores with no global cap, and the TUI defaults to one slot each, so a user turn and a cron turn overlap on one loop. Both ends gate on zero, and the last turn out adopts so a park cannot outlive the turns it waited on.spawncaptures the pair it was asked for and passes it down; capturing later would miss the window where a spawn waits on the concurrency gate and a sandbox boot.This is the second line of defence, not the first.
tui_rpc.methods.configalready rejects a switch outright when the caller's own session has a turn in flight; the park covers what that guard cannot see -- a caller that passes nosession_id, and proactive turns running in their own lanes. Note the RPC still answersapplied: Trueand the config file is already written, so a parked switch is applied on disk while the loop reports the old model until the last turn drains.Also here
curator_modelis re-derived on a switch with the constructor's own expression, so the same config cannot mean one thing at build time and another after. The default is non-empty, so in practice it is a pin; an explicitly emptycontext.curator_modelfollows the agent model, and now follows it in both places.set_provideron the context-engine ABC is concrete so a future implementation with no LLM-backed segment is not forced to write an empty override.ContextAssembleris the only one today and does override it.Scope
AgentLoopand the subsystems it builds.HeartbeatServiceand the Sentinel stack take the same provider but are siblings on the gateway side, which registers no tui_rpc methods, soloop.set_providercannot and does not reach them. Not reachable today; worth an issue if the two sides ever converge.MemoryConsolidatoris re-pointed but its detached consolidation tasks are not snapshotted -- a single call rather than a multi-turn conversation, so the split-conversation argument does not apply, but it is the same shape.Type
Verification
That failure is not this branch.
tests/test_cli_theme.py::test_bold_accent_renders_styled_not_barefails the same way on an unmodified
mainat53aeb0cwhen the whole file runs (verified in adetached worktree) and passes when the single test runs alone; it is a
COLORTERMartifact and CIis green on it.
tests/test_default_context_engine.py::TestTwoTrackConcurrency::test_skill_and_memory_run_concurrentlyalso failed in some runs of this branch and of unrelated ones -- a timing assertion that flakes
under full-suite load, passing alone and with its own file. A clean re-run at this head has only the
theme failure.
A later review round found one of these mutations still surviving -- moving the spawn snapshot into
_run_subagent_inner-- because neither existing test could see the window it exists for: onestubbed
_run_subagentwholesale (provingspawnpasses a pair, not when the pair is read) and theother called
_run_subagent_innerdirectly, bypassingspawn, the concurrency gate and the sandboxboot. There is now a test that holds the gate shut, switches the provider while the task sits in
that window, releases it, and asserts which provider actually served the call. The same round found
two docstrings scoped wider than the code: the mid-turn split does not raise on the
chat_with_retrysites but does on_llm_call_stream, which is the path a TUI turn takes; and thecontext-engine ABC's concrete no-op was justified by a reason an abstract method would satisfy
equally. Both corrected.
The tests here were rebuilt after a review found the previous set only exercised the dispatcher -- replacing any receiver's
set_providerwithpassleft it green. They now build a realAgentLoopand assert the gate, rewriter, curator, curator assembler, history trimmer, subagent manager and consolidator all moved; guard the attribute names the fan-out walks against a rename; and drive the realrun_turnand the real_run_subagent_inner. Verified by mutation -- each of these turns something red:SubagentManager.set_provider->passCuratorSegmentBuilder.set_provider->passsubagentsattribute the fan-out reachesfinallythat releases the turn slotrun_turnentry unconditionallyset_providerdrops the re-derive_run_subagent_innerRisk
_run_subagent/_run_subagent_innertake the provider and model as parameters now; the two suites that stub them are updated. No public API changes. Rollback is a revert -- the previous behaviour is the 401.Related Issues
N/A