feat(agent): bind the model to the conversation, not the process - #284
Conversation
e9176cd to
6882916
Compare
## Summary A live `/model` switch rebuilt the provider but only reassigned `loop.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_provider` now 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 `self` at call time, so an unconditional swap relays one conversation across two vendors. How that surfaces depends on the path: the `chat_with_retry` sites turn a rejected request into `finish_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 only `TimeoutError` and 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 `asyncio` makes at task creation -- if the two land together, the park described here exists only between the two merges. - **The loop parks.** A switch arriving while any turn runs is held and adopted at the next `run_turn` entry. One boundary covers eight `self.provider` reads in `loop/main.py` plus 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: `OriginPools` gates 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. - **Subagents snapshot.** A spawn is a detached task that outlives the turn, so the park cannot reach it. `spawn` captures 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.config` already 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 no `session_id`, and proactive turns running in their own lanes. Note the RPC still answers `applied: True` and 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_model` is 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 empty `context.curator_model` follows the agent model, and now follows it in both places. - The concrete no-op `set_provider` on the context-engine ABC is concrete so a future implementation with no LLM-backed segment is not forced to write an empty override. `ContextAssembler` is the only one today and does override it. ### Scope `AgentLoop` and the subsystems it builds. `HeartbeatService` and the Sentinel stack take the same provider but are siblings on the gateway side, which registers no tui_rpc methods, so `loop.set_provider` cannot and does not reach them. Not reachable today; worth an issue if the two sides ever converge. `MemoryConsolidator` is 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 - [x] Fix ## Verification ``` uv run pytest tests/ -q 5302 passed, 1 failed uv run ruff check raven/ tests/ # All checks passed uv run ruff format raven/ tests/ # unchanged ``` That failure is not this branch. `tests/test_cli_theme.py::test_bold_accent_renders_styled_not_bare` fails the same way on an unmodified `main` at `53aeb0c` when the whole file runs (verified in a detached worktree) and passes when the single test runs alone; it is a `COLORTERM` artifact and CI is green on it. `tests/test_default_context_engine.py::TestTwoTrackConcurrency::test_skill_and_memory_run_concurrently` also 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: one stubbed `_run_subagent` wholesale (proving `spawn` passes a pair, not when the pair is read) and the other called `_run_subagent_inner` directly, bypassing `spawn`, the concurrency gate and the sandbox boot. 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_retry` sites but does on `_llm_call_stream`, which is the path a TUI turn takes; and the context-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_provider` with `pass` left it green. They now build a real `AgentLoop` and 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 real `run_turn` and the real `_run_subagent_inner`. Verified by mutation -- each of these turns something red: | Mutation | Result | |---|---| | `SubagentManager.set_provider` -> `pass` | 3 failed | | `CuratorSegmentBuilder.set_provider` -> `pass` | 1 failed | | rename the `subagents` attribute the fan-out reaches | 2 failed | | delete the `finally` that releases the turn slot | 3 failed | | adopt on `run_turn` entry unconditionally | 1 failed | | never park | 2 failed | | spawn without the snapshot | 1 failed | | curator `set_provider` drops the re-derive | 1 failed | | move the spawn snapshot into `_run_subagent_inner` | 1 failed | - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes `_run_subagent` / `_run_subagent_inner` take 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 --------- Co-authored-by: arelchan <204152633+arelchan@users.noreply.github.com> Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
Review of the three commits stacked on #282 (3eaf3a0..03982ae)
Read every hunk plus the surrounding call graph -- provider pool, the binding
contextvar, the loop's holders and fan-out, the session and model RPC handlers,
the TUI slash command and the picker -- and reproduced the crash and the
mis-pairing below in a throwaway worktree at 03982ae rather than reasoning
about them from the source.
The shape is right
Making the model a ModelBinding -- a model id and the credential that serves
it as one value -- and capturing it once at run_turn entry into a ContextVar
is the correct answer to the bug this line of work started from. Specifically
checked and found sound:
run_turn's session key (req.conversation or f"{channel}:{chat_id}")
matches what the spine hands it, and the binding is captured before the
turn's first read, so a mid-turn switch really is invisible to that turn
without any parking. Deleting_turns_in_flight/_pending_provideris
justified.- ContextVar isolation across concurrent turns and detached work:
asyncio.create_taskandasyncio.to_threadboth copy the context, and
there is norun_in_executoron the LLM path. AgentLoop.provider/modelbecoming read-only properties breaks no
production caller -- the remainingself.provider = ...assignments in the
tree all belong to other classes.ContextBuilderis handed the provider too and is not in the fan-out, but
itsllm_providerreachesLocalSkillCatalog, where the parameter is
accepted and unused. Not a missed holder.- The curator's slow path calls through
_curator_binding()(pair) rather
than through theprovider/modelproperties (which resolve to the
conversation), so the pin is not half-applied there. - The emptied config defaults (
summary_model,detect_model,
smart_routing.tiers) genuinely have no readers today. - Fork metadata inheritance and the binding released on
session.deleteare
correct.
uv run pytest tests/test_provider_pool.py tests/test_agent_loop_session_model.py tests/test_tui_rpc_model.py tests/test_tui_rpc_config.py tests/test_tui_rpc_session.py -q
-> 218 passed, 18 skipped on 03982ae.
Findings
Five, all inline. The first two are in the last commit (03982ae, the
subsystem-pin-as-a-pair change); both were reproduced, not inferred.
raven/providers/pool.py:183--_has_credentialsfeeds a possibly-None
spec intoschema._has_credentials, which dereferencesspec.is_oauth. A
curator_providernaming a vendor Raven has no spec for aborts
AgentLoop.__init__. The call sits outside thetrythis commit
deliberately broadened for exactly this reason.raven/providers/pool.py:129-- with a gateway forced and the pin's
provider unset, the pin still binds(gateway, raw upstream id)and returns
a binding.curator_model: gemini-2.5-flashunderopenrouterstill 404s
and is still swallowed by the curator's fallback, with nothing logged. That
is the defect the description reports as fixed ("logged and dropped"),
surviving for precisely the configs that carried the old shipped default,
and no test covers the unset-provider branch.raven/tui_rpc/methods/model.py:204--model.optionsre-derives the
provider from the model id for a session that has its own binding, so the
picker marks the wrong row for a gateway session. The right answer is on the
binding and on the session record. A new test currently asserts the guess.ui-tui/src/app/slash/commands/session.ts:92--/modelsends
scope: 'session'with a possibly-nullsid, which the server now refuses,
and the chain has no.catch(...). The user gets no transcript error and a
rawunhandledRejectionline over the Ink render. The missing.catchis
pre-existing in this file; this commit is what makes it easy to reach.raven/tui_rpc/methods/config.py:467--_remember_session_modelsaves
unconditionally, so a/modelbefore the first message materialises an empty
session record and a phantom row in/sessions list.session.title
handles the same case correctly one file over.
Lower confidence, worth a look
_restore_session_modelis wired only intosession.resumeand is the
session record's only reader, while the record is written for any session key.
Unreachable today becauseconfig.setis TUI-only, so this is latent rather
than a defect: a non-TUI session (or a CLI path continuing a session) that
ever acquired an override would run the default after a restart while its
record said otherwise.QueryRewriter._call_providerpasses no model and relies on "the pool builds
each provider with the bound model as its default". True for pool-built
bindings, and the construction sites look consistent, but it is an implicit
contract with no test:AgentLoop(provider=p, model="X")wherep's default
is notXwould silently rewrite on the wrong model.- Not a defect, a product call worth confirming: with
context.curator_model
now unset by default, the curator's slow path moves from "fails on a Gemini id
nobody had a key for and drops to the deterministic plan" to up to 12
tool-calling requests per turn on the conversation's model. The description
says this plainly; flagging it because it is the new default, not an opt-in. tests/test_tui_rpc_model.pyhas 18 parameterized skips. Pre-existing, not
from this PR, but they narrow that file's green more than the count suggests.
gloryfromca
left a comment
There was a problem hiding this comment.
Blocking: context.curator_provider naming a passthrough vendor takes the agent down at construction -- @gloryfromca's pool.py:183 finding, which I reproduced independently. Nothing else I found rises above a suggestion.
I read git diff github/main...HEAD (47 files) plus the callers, and main's version of each thing that moved. Covered: the diff itself; the three production AgentLoop(...) sites; backward compatibility of the RPC surface; AGENTS.md (comment language, test-file naming, repo assets, CONTEXT.md domain terms); and whether tests were weakened to look green.
The blocker
Reproduced on 03982ae, independently of the thread:
Config.model_validate({'providers': {'mistral': {'apiKey': 'sk-test'}}})
ProviderPool(cfg).bind_pin('mistral-large-latest', 'mistral')
-> AttributeError: 'NoneType' object has no attribute 'is_oauth'
curator_provider is new in this PR, is str | None with no validation, and ProvidersConfig documents passthrough vendors as supported ("a provider LiteLLM supports but Raven has no spec for still works from config alone", schema.py:402-404). So this PR adds a field whose documented-valid values stop AgentLoop.__init__, from a call site the same commit deliberately widened a guard for. Detail and the fix I verified are in that thread.
What I tried to break and could not
- The binding itself.
run_turnkeys onreq.conversation or f"{channel}:{chat_id}", andmethods/turn.py:154passesconversation=parsed.session_key-- the same string the TUI handsconfig.setassession_id. The write half and the read half agree, so a switch is not landing on a key no turn reads. - Lost pins. All three production
AgentLoop(...)sites (agent_commands.py:325,gateway_commands.py:227,tui_commands.py:443) pass aProviderPool, so no entry point silently losescurator_model/llm_gate_model. - A half-pair reaching the wire. The curator slow path calls
binding.provider.chat_with_retry(..., model=binding.model)off one_curator_binding()(segments/curator.py:247-253).CuratorAssembler.provider/.modelresolve the turn's binding rather than the pin, which is right: those feed the trimmer's window arithmetic, which is about the conversation. restore_session_model's narrowexcept (SystemExit, RuntimeError, ValueError)looked like the blocker's twin, given the same commit widenedbind_pinto bareExceptionfor exactly that reason. It is not:check_provider_credentialsraisestyper.Exit, whose MRO is(click.exceptions.Exit, RuntimeError, ...), and both OAuth provider constructors are two assignments. I could not construct a failure the tuple drops. Withdrawn.- Cost of the config supplier.
ProviderPool.configre-reads throughload_runtime_config(None, None)on every fingerprint, gateway lookup and credential check -- roughly 4 loads perbind_pin. Measured at 0.68 ms per load, and no per-turn path touches it (binding_for_sessionis a dict read). Not worth raising. - Weakened tests. The four edits to existing tests (
test_read_file_image.py,test_sandbox_unit.py,test_subagent_manager.py,test_rpc_schema_match.py) are mechanical signature and attribute updates that follow the production change; no assertion was loosened, and no skip or xfail was added.tests/test_agent_loop_session_model.pyasserts the real things -- two concurrent turns on two models, a mid-turn switch landing on the next turn, a detached subagent keeping its spawn binding, and the restore path in both directions. - Dropping error code -32009. Safe both ways: an older client meets a server that never raises
model_switch_in_turn, a newer client meets an older server and falls through to a generic message.is_turn_activeis still live forsession.py:504,536andturn.py:138, so nothing was orphaned.
Test state I measured, at 03982ae unmodified
uv run pytest tests/ -q 1 failed, 5362 passed, 58 skipped, 13 deselected
uv run ruff check raven/ tests/ All checks passed
npm test (ui-tui) 974 passed (85 files)
npx tsc --noEmit clean
npm run lint:rpc generated.ts in sync
npx eslint src/ 0 errors, 22 warnings (pre-existing, untouched files)
The one failure is tests/test_cli_theme.py::test_bold_accent_renders_styled_not_bare, pre-existing and unrelated to this diff. One correction to how the description characterises it: it is not load-dependent. It fails deterministically whenever tests/test_cli_theme.py runs as a whole file (1 failed, 44 passed in 0.33s) and passes when run alone, so it is ordering inside that single file. Out of scope here, but it will not go away on a quieter machine.
| self.memory_consolidator.set_provider(binding.provider, binding.model) | ||
|
|
||
| def set_provider(self, provider: LLMProvider, model: str) -> None: | ||
| """Change the default binding. Kept for callers that are not |
There was a problem hiding this comment.
set_provider's docstring names two callers that do not exist.
AgentLoop.set_provider has no production caller -- not on this branch, and not on main either:
$ git grep -n "set_provider" HEAD -- raven/ | grep -v "def set_provider"
raven/agent/loop/main.py:716-718 # the three subsystem setters, from set_default_binding
raven/context_engine/... # the subsystem setters themselves
Nothing calls loop.set_provider / agent.set_provider / agent_loop.set_provider. _set_model goes through set_default_binding now, and on main it assigned loop.provider / loop.model directly (methods/config.py, the two lines this PR replaces), so the method was already dead there. The only callers are tests/test_agent_loop_model_switch.py:92 and :117.
Not a runtime defect. The concrete cost is that the next reader takes "the gateway, a CLI one-shot" at face value and keeps both the method and the two-argument shape it implies, on the belief that an entry point depends on it. That is the same error the description says it corrected by deleting ProviderPool.default ("had no production caller at all: what a new session starts on is AgentLoop._default_binding") -- this one survived the same pass.
Either drop it and have those two tests call set_default_binding, or say what it is: a wrapper with no caller today, kept for shape.
03982ae to
6beb459
Compare
|
Claude finished @arelchan's task in 2m 7s —— View job Review of
|
| .catch(ctx.guardedErr) | ||
| // Without this the rejection is unhandled, and setupGracefulExit writes | ||
| // it raw to stderr over the Ink render: `/model` typed before the first | ||
| // session.create resolves is refused by the server (session scope with | ||
| // no session), and the transcript would show nothing at all. | ||
| .catch(ctx.guardedErr) |
There was a problem hiding this comment.
[nit] .catch(ctx.guardedErr) is chained twice here (lines 145 and 150), introduced together in eb83925 when the missing-catch bug was fixed. The second one is dead code — the first .catch already converts the rejection into a resolved promise, so the chain never reaches the second handler. Harmless, but worth collapsing to one .catch(ctx.guardedErr) (and the explanatory comment can stay on it).
| .catch(ctx.guardedErr) | |
| // Without this the rejection is unhandled, and setupGracefulExit writes | |
| // it raw to stderr over the Ink render: `/model` typed before the first | |
| // session.create resolves is refused by the server (session scope with | |
| // no session), and the transcript would show nothing at all. | |
| .catch(ctx.guardedErr) | |
| .catch(ctx.guardedErr) |
gloryfromca
left a comment
There was a problem hiding this comment.
Blocking: bumping CURRENT_CONFIG_VERSION re-runs the context-window migration on every already-stamped config and deletes a contextWindowTokens: 65536 the user restored on purpose -- inline note on raven/config/loader.py:19. Everything else on this revision is a suggestion.
New head since my last pass (03982ae -> 6beb459, seven commits). I reviewed the delta rather than the whole diff again: the rebase onto a main that grew providers/auth, providers/wire, providers/pin and the lazy/per-model provider layer, plus the two features added after review -- retiring the auto provider, and requiring the provider as a spoken word on three surfaces. Covered: the delta and its callers; main's behaviour where this branch replaces it; the migration path for an existing config; AGENTS.md (comment language, test naming, CONTEXT.md domain terms); and whether tests were weakened.
Previously-raised findings, verified rather than taken on trust
- pool.py passthrough crash -- fixed, and the author's account of how is right:
schema._has_credentialsnow takes the name separately (schema.py:501) instead of readingspec.is_oauthoff a None. Re-ran the reporter's repro:bind_pin('mistral-large-latest', 'mistral')returns a binding, and empty / absent /apiBase-only sections each warn and returnNone. /modelbefore the firstsession.create-- fixed, and the sweep went past the one call I said was in scope. Fine by me; the file was the outlier.- The lazy session record -- fixed with the
sessions.existsguard, and the metadata still rides the first real save. - The picker's provider row -- fixed as a side effect of
stored_model_idbecoming unconditional, not by either fix discussed in that thread. Details, and the one vendor class where the symptom survives, in the thread. - Still open, all nonblocking, none of them re-reported here:
AgentLoop.set_provider's docstring still names two callers that do not exist (main.py:734); the gateway-pin path is still unlogged andCONTEXT.md:66("A pin that cannot be paired is reported and dropped") plusraven.py:96("Unset falls back to deriving the vendor from the id") still describe a rule the code does not follow on that branch.
The new features, tried and not broken
- Retiring
auto. Everyprovider == "auto"test I could find also accepts""(pool.py:112,151,159,175,schema.py:922), so a config the migration cannot write to keeps deriving instead of failing. Thechanged = X or changedshape in_persist_migrationsdoes run both migrations rather than short-circuiting. provideras a required word._set_modelrefuses a missing provider at the boundary, andMissingCredentialsErroris caught explicitly (config.py:356) rather than falling into theRuntimeErrorbranch wherestr(typer.Exit)used to surface aserror: "1"-- the same class the rebase fixed inrestore_session_model, handled here too.- Where I expected
stored_model_idto break the gateway case it did not:stored_model_id('openrouter', 'anthropic/claude-haiku-4-5')givesopenrouter/anthropic/claude-haiku-4-5, which is the correct LiteLLM spelling, not a double prefix. provider: "auto"typed as the word writesauto/<model>throughstored_model_id, but only reaches disk whenagent_loop_factoryyieldsNone. With a real looppool.bindraisesMissingCredentialsErrorbefore anything is persisted, so the user gets an error and the config is untouched. Withdrawn.- Tests: no skip or xfail added; the edits to existing tests in
c3e300f1replaceloop.model = ...assignments with binding calls, which is what a real caller does, and the gate tests now assert the rule in both directions instead of the forwarding they used to assert. Nothing loosened.
Test state I measured, at 6beb459
uv run pytest tests/ -q 1 failed, 6443 passed, 43 skipped, 13 deselected
uv run ruff check raven/ tests/ All checks passed
npx vitest run (ui-tui) 991 passed (86 files)
npx tsc --noEmit clean
npm run lint:rpc generated.ts in sync
The one failure is tests/test_cli_theme.py::test_bold_accent_renders_styled_not_bare, a file this PR does not touch; it fails identically when that file runs on its own (1 failed, 44 passed in 0.26s), so it is ordering inside that file and not attributable here.
One unrelated ask
ui-tui/package-lock.json is in the diff only to strip the libc arrays off a dozen optional dev dependencies -- an artifact of an npm older than the one that wrote main's lockfile. Nothing in this PR needs it. Worth reverting that file so it does not flip back and forth between contributors.
Review at
|
|
I checked your review against the tree rather than taking it, since two of the items are about things I said. Four notes; the branch has not moved, so this is not a re-review. R8 is right and I was wrong. The R2 and R4 are real, and my summary on this head was wrong to say "everything else on this revision is a suggestion". Both verified here before I say so:
Credit is yours on both; I am not re-anchoring them. But the scope sentence in my summary should read "plus R2 and R4", and I would rather say that than leave it standing. The theme test -- we can both stop wondering, and the author's framing was closer than mine. It is The repo's own Where we differ, mildly: R3. Same facts -- I reproduced the passthrough case and posted it in that thread with a patch. I called it too narrow to hold the PR for; you call it before-merge. That is a judgement call about one picker row, not a disagreement about the code, and it is the author's to settle. My position on this revision is unchanged and unchanged for R1's sake: it should not merge until the migration watermark is per-migration. R2 and R4 are now on that list too. |
|
Round 2 addressed at R1 -- each run-once migration now carries the generation that introduced it and is gated on that, so a bump cannot resurrect a past one. Your diagnosis of why the existing test could not see it was the important half: R2 -- both setters call one R4 -- the flag rides into the overlay state and back out through the picker's callback; the refusal keeps it in both halves of its suggestion; the footer writes R3 -- taken, with @gloryfromca's patch. His narrowness call is fair, but the row a user reads to answer "whose key is paying for this" is not a row I want wrong, and the fix is small now that the id names its provider. A head naming no configured provider stars nothing. R5, R6 -- R7 -- description rewritten. "Dissolved by the work itself" was wrong about both R3 and R6; it now says three rounds as they actually went. Also corrected: the Risk section's claim that the sidecar "is ignored by any build that does not look for it" -- every build since #341 does look, and after a revert reads R8 -- taken as written, and thanks to both of you for converging on it rather than leaving me two contradicting reviews. The release note is unchanged. Follow-ups taken, limited to defects in what this PR itself added or removed: Left for their own issues, as you both suggested: the deleted AST guard from The theme test: confirmed |
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned. My R1 was the one thing I was holding it for, and it is properly fixed -- not papered over.
Six commits on 6beb459. I read the delta, re-ran every reproduction I had posted, and mutation-checked the fix to my own finding rather than taking the claim.
R1, the blocker I raised -- fixed, and the test now discriminates
Per-migration floors (_CONTEXT_WINDOW_MIGRATION = 1, _AUTO_PROVIDER_MIGRATION = 2), with _migrate_config's from_version defaulting to the current mark so a caller that cannot read the watermark runs none. Both directions measured on d7bd1c67:
gen-1 stamp + hand-restored 65536 in-memory 65536 on-disk 65536 stamp -> 2 notices 0
gen-1 stamp + provider: auto on-disk provider -> openrouter stamp -> 2
unstamped + top-level skillRouter on-disk provider -> openrouter (was silently skipped and stamped)
And the test genuinely holds it. Setting _CONTEXT_WINDOW_MIGRATION = 2 -- the shared-mark behaviour, one line -- turns test_context_window_pin_survives_once_stamped and test_a_later_generation_does_not_reopen_a_migration_already_run red. The precondition is now the literal {"version": 1} rather than CURRENT_CONFIG_VERSION, which was the half of my note that mattered.
The rest, verified rather than assumed
- R2 --
_forget_transport_verdicts()on both setters,_vision_okincluded. I checked the objection I would have raised against clearing wholesale on every session switch:supports_image_tool_resultis pure spec/type inspection with no I/O (capabilities.py:83-108), so a re-probe costs nothing. The "computed from the provider" sentence is back at:360-362. - R3 -- reads the stored head. Measured:
mistral/mistral-large-lateststarsmistral,openrouter/anthropic/claude-haiku-4-5starsopenrouter, and an unknown head stars nothing rather than the configured default. - R4 -- all four points closed, and the
boolean -> boolean | 'default'widening is safe: every reader ofoverlay.modelPickertests truthiness (overlayStore.ts:29,useInputHandlers.ts:168,appOverlays.tsx:137,165), none compares=== true. - F1 / R5 / R6 --
set_provider's docstring now says "no production caller today" and carries_configured_window(whichModelBindingdoes accept as its third field);Provider Pinis nowConfigured providerwith the collision againstSubsystem pincalled out in_Avoid_, no danglingproviders/pin.py, and the claim is verifiable against_migrate_auto_provideras I ran it; all four gateway sentences now describe the asymmetry.git grep "reported and dropped\|deriv\w* the vendor from the id"leaves only the one occurrence that is now true of the explicit branch. - Follow-ups --
clear_session_bindingout of theif removedconjunct,provider=""instead of"auto"in onboarding, thelazy.pydocstring, andpackage-lock.jsonback to main (git diff github/main...HEAD -- ui-tui/package-lock.jsonis empty). The five items deferred to their own issues are the right five; none is a defect this PR introduced. - Tests weakened? The opposite, and the author said so rather than leaving it: one existing vitest case had been pinning R4's dropped flag in place, and it now asserts the spelling that works. Nothing else was loosened; no skip or xfail added.
Test state, measured on d7bd1c67
TERM=dumb uv run pytest tests/ 6689 passed, 33 skipped, 13 deselected, 0 failed
uv run ruff check / format clean, 832 files formatted
npx vitest run (ui-tui) 993 passed (86 files)
npx tsc --noEmit clean
npm run lint:rpc generated.ts in sync
npx eslint src/ 0 errors, 22 pre-existing warnings
Closing the loop on the disagreement from the last round: TERM=dumb is what makes the theme test pass, so nothing fails here any more. My earlier count (6443) versus yours was my own environment -- the channel SDKs got installed into this venv part way through, so those seven files collect instead of skipping. The branch never had anything to do with either number.
One nonblocking note inline, about a test that stopped compiling and a seam left untested. Neither misbehaves at runtime.
…#346) ## Summary Three follow-ups from #341, which fixed a retired `contextWindowTokens: 65536` capping every upgraded install at 64k. Two are its loose ends; the third is the reason it happened at all. **The notice no longer prints on stdout.** #341 drains config-migration notices in the `run()` wrapper so every command tells the user, which caught the two whose stdout is a machine-readable document: `raven doctor --json` and `raven import --json`, both advertised for automation. Reproduced -- a config still carrying the retired pin makes `doctor --json` emit JSON with a sentence after it, and `jq` refuses the result. Moved to stderr, where the rest of this class of message already goes (the `ConfigReadError` branch a few lines up in the same wrapper, and the loader's malformed-config warning). That fixes any future `--json` command without it having to know this exists, which allow-listing the two would not. The window is narrow -- the migration fires once per install -- but the run it can spoil is a first run after upgrading, and a CI job is exactly the caller not watching. **`raven doctor` gains a Config section and a `--fix`.** The premise changed while building it: the migrations run at load, so by the time this command looks, nothing is pending -- they already happened, silently, one launch ago. What is left for a person to ask about is what Raven deliberately will not decide for them. So it reports a `contextWindowTokens` pinned below what the configured model holds, and names what rides on it: the per-turn history budget, when the Curator starts paying for a slow path, and when memory consolidation archives. `--fix` removes the pin. Reported without `--fix`, never written: that pin is a real configuration for a local model or an endpoint served smaller than the catalogue thinks, and the flag is the user saying which case theirs is. The write carries the file mode across (config.json holds `providers.*.apiKey`) and uses a per-PID temp name -- the same rules the loader's migration writer follows. One check rather than two. A config naming no provider is the other thing worth asking about, but it reads differently on either side of the explicit-provider rule in #284, so it belongs to that change; a model that routes to nothing is already reported above it. **The bootstrap stops writing every default into the user's config.** This is the cause of the bug #341 cured. `save_config` dumped the whole `Config()`, so a new install started life with eight kilobytes of settings its owner had never heard of and, worse, with that day's defaults frozen into their file -- after which a default we improve never reaches anyone who already has one. `contextWindowTokens: 65536` got onto disk exactly this way. It now writes only what differs from the defaults. Lossless on reload, since a value equal to its default reloads as that default. A fresh config drops from 7,985 bytes to 2, and to 634 once the extension blocks are seeded -- what is left is the handful of lines someone actually chose, which is also a config they can read. That third one is the only change here with a broad blast radius, and it has one visible consequence, which is an improvement: the onboarding picker used to lead with the schema's default model, because the bootstrap had written it into the config and the wizard read it back as "the current model" -- a value nobody had chosen, offered as though somebody had. With nothing written, the wizard leads with the provider's own recommendation instead. The test that pinned the old list now pins the new one and says why. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification ``` pytest tests/ 6650 passed, 1 failed, 33 skipped (tests/test_cli_theme.py::test_bold_accent_renders_styled_not_bare, which fails identically on github/main when its file runs together and passes alone on both) ruff check raven/ tests/ All checks passed ruff format clean ``` Driven by hand as well as by tests, each against a throwaway home: ``` doctor --json on a fossil config stdout parses as JSON again; the notice is on stderr doctor (pin 65536, model 1M) reports the pin and what is sized against it doctor --fix pin removed, file still 0600, a second run reports nothing save_config on a fresh Config() 2 bytes; 634 after the extension blocks are seeded ``` New cases cover the stderr split, both doctor paths including the mode preservation, and the save round-trip keeping every value the user chose. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk The bootstrap change alters what every new install's config file looks like: nearly empty instead of a full dump. Existing configs are untouched -- nothing rewrites them -- and both shapes load identically, so the risk is confined to anyone reading `config.json` as documentation of what can be set. `raven doctor` and the schema remain the answer to that question. The onboarding picker's default moves from the schema default to the provider's recommendation, as described above. `doctor --fix` writes only when asked, and only removes a key. Everything else here is output routing. Rollback: revert the commits. Nothing persists a new format, so a reverted build reads every config written under this one. ## Related Issues N/A --------- Co-authored-by: arelchan <204152633+arelchan@users.noreply.github.com> Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The model was two attributes on a process-wide loop, so there was one answer for everyone: two sessions could not run different models, a switch in one moved all of them, and the "default" was whatever the last switch happened to write. This makes the model a property of the conversation. A ModelBinding is a model id and the credential that serves it, as one value. ProviderPool is the single place a model id becomes such a pair, caching per (vendor, model) because building one imports LiteLLM and writes its vendor's key into the environment. run_turn resolves the binding for the turn's session and holds it in a context var for the whole turn tree; the loop's provider/model, the context engine's LLM-backed segments, the skill gate and rewriter and the consolidator all read that instead of a reference of their own. What that buys, rule by rule: - Different sessions on different models, and a switch that moves only the session that asked: a dict of overrides, read at turn entry. - A new session on the configured default: a session-scoped switch does not write agents.defaults, so nothing accumulates. config.set model takes a scope, session (what the picker sends) or default, and /model <name> --default is the counterpart in the TUI. The session's choice is stored on its own record and restored on resume, so it outlives the process without becoming everyone's default. - A subsystem with a model and credentials of its own uses them, otherwise it follows the conversation: the factory resolves each pin through the pool, so a holder has either a complete pair or nothing. A gateway binds a pin through itself, since it serves any id under its own key. - A switch mid-turn landing on the next turn: free. The turn holds the binding it entered on, so a later switch is not visible to it. The client-side refusal that used to pre-empt this is gone, and so is ModelSwitchInTurnError across the Python errors module, the TypeScript client, the OpenRPC schema and the code-table test. Detached work inherits the context copy asyncio makes at task creation, so a spawned subagent finishes on the model it was spawned under; spawn also passes the pair explicitly, because a subagent outlives its turn and that is worth being able to read in the code. The picker and session.info now report the session's own model rather than agents.defaults, which otherwise showed two models for one conversation. No subsystem ships a vendor default any more. context.curator_model, token_wise.tool_result_lifecycle.summary_model and skill_forge.detect_model all hardcoded the same Gemini id and token_wise.smart_routing.tiers shipped six models across three vendors, for users who may hold no key for any of them. All four are unset now, which is what "not configured" has to mean for the rule above to be expressible. Only curator_model has readers today; the other three are dead config, emptied for consistency. The media tools keep their defaults, in the tool code rather than the schema: they are capability-bound, and no conversation model generates images or speech. Note for the release: with context.curator_model unset, the Curator's slow path runs on the conversation's model instead of failing on a Gemini id nobody had a key for and dropping to the deterministic plan. That is the rule working as asked, but on a long conversation it is up to 12 tool-calling requests per turn of context housekeeping that previously cost nothing. Set context.curator_model to a small model, and configure that vendor's key, to keep it cheap. Review of this branch by four agents in isolated worktrees found the persistence half-built (the write to the session record had no reader, so a switch died with the process while the code said otherwise), the provider pool handed a config snapshot at all three construction sites so the freshness it documents never fired, and the picker overriding its selection for every session because session_model falls back to the default and so never answers None. All are fixed here, along with the gateway pin escaping the factory's guard, a deleted session leaving its override behind, and a fork dropping its parent's model. On the TUI side two existing tests asserted the config.set params by exact equality and would have gone red in CI on the new scope key; a default-scoped switch painted the new default into the status bar while the session kept its own model; and --default is now stripped in any position and however many times, with /model --default alone opening the picker instead of sending an empty model id. The mutation pass left seven holes green, all the same shape: a helper tested directly while the handler or registration calling it was not. Each now fails a test when broken, including the window the spawn snapshot exists for -- the concurrency gate and sandbox boot a spawn waits through before its task starts running. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
An explicit scope="session" carrying no session id fell through to the default branch, so it wrote agents.defaults.model and moved every session that never switched -- reachable from the TUI, whose session_id is null until the first session.create resolves and after a failed one. It is refused now rather than widened. A fork re-pointed its parent's live binding but never copied the record, so a branched session kept its model until the first restart and then dropped to the default. SessionManager.fork carries model and provider, which covers every caller rather than only the RPC handler. /model <name> --default refused to repaint the status bar, on the theory that a default-scoped switch cannot move the asking session. That holds only for sessions which already chose their own model; a fresh conversation reads the default and does move, and it is the common case for that command. Whether it moved is now the server's answer (applies_to_session) rather than something the client infers from the scope, and an unapplied switch is reported as an error instead of being drawn as a success. Also closes the test gaps a review round found: the conjunct that routes --default, the fork inheritance, the binding released on session.delete and the production registration that makes the picker session-aware were each removable without turning anything red. One stale assertion message named a helper that no longer exists. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
context.curator_model and skill_forge.llm_gate_model took a model id and nothing else, so the pool had to guess which credential served it -- the same shape as the mis-pairing this line of work is about, one layer up. The guess is unanswerable once a gateway is configured: openrouter serving anthropic/claude-haiku-4-5 and anthropic serving claude-haiku-4-5 are both valid, name different credentials and different bills, and the id does not distinguish them. Guessing "the configured gateway serves everything" then handed the gateway an id it has no route for, and the 404 was swallowed by the subsystem's own fallback -- a pin that looked configured and never ran. Each pin now takes a provider alongside the model, curator_provider and llm_gate_provider. Set, nothing is derived. Unset, the vendor is still derived from the id, which is what every existing config gets and what keeps them working. Either way a pin that cannot be paired is logged and dropped rather than silently borrowing the conversation's key. bind_pin's guard is broadened from the credential exceptions to anything: it runs in the context-engine factory at construction and building a provider imports a vendor module, so a misconfigured pin could stop the agent from starting -- the opposite of what its docstring promised. ProviderPool.default is deleted along with the two tests that covered it. What a new session starts on is AgentLoop._default_binding, built in the constructor, so the method had no production caller and the tests protected dead code. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Two integration defects the replay onto current main surfaced, neither of them a text conflict: `restore_session_model` guarded on `(SystemExit, RuntimeError, ValueError)`, a tuple written before `MissingCredentialsError` existed. Building a provider now raises it, so a session whose stored model lost its credential failed the resume instead of falling back to the default -- the opposite of what the docstring promises. Broadened to `Exception`: what building a provider can raise is open-ended (it imports a vendor module), and a worse session beats no session. `_build_rewriter_and_gate` kept `model=` at one call site after the parameter was dropped, and a test kept asserting two mechanisms this line of work deletes (`_image_tool_result_ok`, the `refresh_context_window` call that only the parked-switch path made). Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
A model's window is a fact about that model, and this line of work makes two sessions on differently sized models a normal thing to have at once. A single int on the loop has no answer that is right for both -- so the window moves onto `ModelBinding`, beside the credential, and every holder reads the binding of the turn it is running under. `refresh_context_window` and its cascade go with it. Their premise was that the loop has one current model, which stopped being true; a turn now enters on a binding that already knows its own size, so there is nothing to refresh and no window left to keep in step. Resolved on first read rather than at construction. Building a provider is what imports LiteLLM, and a lazily built one has not done it yet, so an eager window is the catalogue-miss default for every model -- the defect `on_built` existed to paper over. By the first read there is a turn in flight and the import has happened. A miss is deliberately not cached, so a cold read cannot make the process wrong for its lifetime. An explicit `contextWindowTokens` travels on every binding the loop and the pool build, so a user who pinned a number keeps it whatever model a session switches to -- the ladder's rule, carried into a world where the model is per session. Holders keep a settable `context_window_tokens`: the getter reads the turn's binding, the setter writes the out-of-turn fallback. Existing callers and tests that assign still mean what they meant. The tests that asserted the old mechanism now assert the same property through the new one -- that a switched session's holders size against the switched model, that a pin outranks it, and that a cold miss is retried rather than remembered. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Five defects, each one a place where this branch and the main it now sits on disagreed about who owns the model. The rewriter kept a `model` parameter that nothing reads: its own design is to follow the conversation and send no model at all, so the argument was a promise the code could not keep. Removed rather than re-honoured -- a parameter that quietly does nothing is worse than an absent one. The gate's tests asserted that a bare `llm_gate_model` is forwarded to the provider. It is not, deliberately: an id with no credential of its own, sent on whatever key the current provider holds, is precisely the mis-pairing this line of work exists to remove. The tests now assert both halves of the real rule -- an unpaired pin is ignored and the gate follows the turn; a pool-paired pin is used as given. Three test files still drove the loop by assigning `loop.model`, which is a read-only view onto the current binding now. They set the binding instead, which is also what a real caller does. `_default_session_info` became a coroutine on main (the usage baseline resolves a window off-thread); the tests this branch added still called it synchronously and subscripted the coroutine. Their MagicMock loops also reported a MagicMock as `model`, which the same baseline then fed to the catalogue's regex -- a real id now, since the bundle genuinely resolves a window from whatever the loop reports. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
`/model` before the first message no longer manufactures a session file. `session.create` is lazy -- it mints a key and writes nothing until the first real save -- so persisting the choice here produced a zero-message record, and `/sessions list` grew an untitled row for every switch made before saying anything. The metadata is still written in memory, so the choice rides the session's first real save; `session.title` guards the identical case the same way. Every rpc call in the TUI's session commands now has a `.catch`. The one the review named is `/model`: typed before the first `session.create` resolves, it sends a session-scoped switch with a null session id, which the server correctly refuses -- and with no catch the rejection reached `setupGracefulExit`, which writes it raw to stderr over the Ink render while the transcript showed nothing at all. Sixteen other calls in the file had the same gap; `core.ts` and `ops.ts` have used this idiom all along. The third finding -- a pin naming a vendor with no spec crashing the agent at startup -- is already gone. Rebasing onto current main brought a `_has_credentials` that takes the provider name separately instead of reading it off a spec that may be None, so `bind_pin` on a passthrough vendor now returns a working binding. Verified with the reporter's own repro rather than assumed. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
``agents.defaults.provider: "auto"`` did not detect anything. A prefixed model id was answered by the provider it names, but a bare id fell to keyword matching in ``PROVIDERS`` order and took the first configured claimant -- so with anthropic and openrouter both keyed, `gpt-4.1` went to openrouter over openai because openrouter sits third in that list and openai eighth. Which vendor's key paid for the call was decided by an array index, and nothing in the config said so. That is the same guess this line of work removes one layer up, where a model id had to name its own credential. Removing it there and leaving it in the config is locking the front door and leaving the back one open. So the field carries no default now, and a one-shot migration resolves each pre-rule config through the old derivation and writes the answer into it. Behaviour is unchanged by construction -- the value written is exactly what that config was already getting -- but it stops being inferred and starts being something the user can read and disagree with. It reuses the sidecar watermark from the context-window migration, so a config is visited once and a value the user sets later is theirs. A config the derivation cannot answer for (no configured provider serves that model) is left blank rather than filled with a vendor chosen to have something there. An empty provider is reported where it is used, which is a better failure than a silent wrong key. ``_match_provider`` keeps the derivation for exactly two callers now: that migration, and a config the migration could not write to. Its docstring says why the field is required, so the next reader does not restore the convenience. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
A model id does not name whose credential serves it. `openrouter` serving `anthropic/claude-haiku-4-5` and `anthropic` serving `claude-haiku-4-5` are both real, name different keys, bill different accounts, and look identical on the wire. Everything that used to fill in the blank was guessing which account pays. So the three surfaces that change a model now require it, in the same words: - `/model <id>` is refused, and the refusal names the two ways out -- `/model` for the picker, or `/model <provider> <id>` to say it outright. A prefixed id is refused too: a prefix is LiteLLM routing syntax, not evidence about a credential. - `config.set model` refuses without a provider, which is the boundary where the rule can actually be enforced rather than asked politely. - `raven provider use` requires `--provider`, and names it in the error along with `raven provider list`. `--default` is not an exception. Changing what new sessions start on is the same choice about the same thing. The picker already asked in this order -- provider, then model, with a free-text row for an id no catalogue lists -- so it needed nothing: it was the only surface that had it right. With nothing left deriving, `providers.pin` goes. Its whole subject was "which provider to pin when the user changed the model without naming one", and its last caller was the onboarding wizard, which asks for the provider before the model and therefore always had one in hand. The tests that asserted the derivation are gone with it; the ones that were about something else now name a provider like a caller does. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
`/model` and `raven provider use` both require a provider now, and both said so only by refusing. The completion list showed "change or show model" and the CLI argument still read "Model id, e.g. anthropic/claude-sonnet-5" -- an example that looks like it names its own provider, which is the confusion the rule exists to end. Both now carry the usage: the slash command's meta line gives the three forms in one line, and `provider use --help` marks the flag required and explains the pair in a sentence. A rule the user meets first as an error is a rule we chose not to tell them. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The watermark was a single scalar shared by every stamped migration, so raising it to 2 for the provider migration re-opened the context-window one on every config already stamped at 1. That deleted a contextWindowTokens 65536 the user had put back by hand, after our own notice invited them to, and printed the same invitation a second time. It also broke the guarantee _write_migration_version states in its own docstring. Each migration now carries the generation that introduced it and is gated on that, so a future bump cannot resurrect a past one. The test that should have caught this built its precondition from CURRENT_CONFIG_VERSION, the very constant that moved, so it could only ever test the generation it was run under. It now pins the literal a shipped build wrote, and two new tests cover the upgrade path in both directions: gen-1 configs keep a restored pin, and still pick up gen 2. Also in the same run-once path: _migrate_auto_provider validated its probe against a strict Config before the shims that relocate legacy top-level blocks, so a config still carrying skillRouter failed the probe, was skipped with a debug line, and was stamped anyway -- never retried, on exactly the oldest configs most likely to still say auto. The probe now keeps only what Config declares, by field name or alias, which the next shim does not have to remember. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
…er differently Deleting _adopt_provider took its _image_tool_result_ok.clear() with it, and neither binding setter replaced it. Both capability caches key on a model id but are computed from the provider serving it, so an apiBase repointed at a box with different capabilities, or a re-authenticated provider, keeps the old endpoint's verdict: the credentials fingerprint changes, the pool builds a new provider, the model id does not move, and images stay dropped from tool results for the life of the process with nothing in the log to say why. Both setters now clear both caches. _vision_ok is included because it has never had an invalidation point at all, and it is the same one line in the same place. The loop holds several providers at once now, so a model-id-keyed verdict answers for more callers than it used to. set_provider also built its binding without _configured_window, so a window the user pinned would be dropped the day that method grows a caller. It carries it now, and its docstring no longer names two callers that do not exist. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Three pieces of copy contradicted each other and the only spelling that actually changes the default appeared in none of them. The picker footer taught `/model <name> --default`, and a bare name is precisely what the parser refuses. The refusal then suggested `/model <provider> <name>` with the flag dropped, so a user who followed our own advice landed a session-scoped switch and was told the model changed -- for a default they had asked to change and had not. The other route dead-ended too: `/model --default` opened the picker, whose selection sent no flag at all, so the picker looked like it had worked. The flag now rides into the overlay state and back out through the picker's callback, the refusal keeps it in both halves of its suggestion, and the footer writes `/model <provider> <id> --default`. The picker also shows which scope its selection will use, since the two look identical otherwise. One existing test pinned the refusal text that dropped the flag, so it was holding the defect in place; it now asserts the spelling that works. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
model.options re-derived the provider from the model id. For a vendor Raven has a spec for that now happens to come out right, because every switch writes the provider into the id through stored_model_id and find_by_model reads that prefix back. It is not a rule, though: for a passthrough vendor -- mistral, xai, the class ProvidersConfig documents as supported -- find_by_model answers None and the provider fell through to agents.defaults.provider. So the picker starred another vendor's row for a session running on this one's key, with the cursor parked on it. The starred row is exactly what a user reads to answer whose key is paying for this session, and the row that should have been starred was listed right there. The stored id still names its provider, so read the head rather than guess. A head naming no configured provider stars nothing, which beats starring a vendor picked to fill the blank. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Four sentences, three of them added by this branch, stated a rule the code does not have on one branch. CONTEXT.md said a pin that cannot be paired is reported and dropped; curator_provider and llm_gate_provider said an unset provider derives the vendor from the id; bind_pin's own docstring said the vendor is guessed. On the else branch a configured gateway wins over that derivation, the pin binds under the gateway's credential, and nothing is reported or dropped. The behaviour is not a regression and is not changed here -- main sends the same pair, and an earlier round settled on keeping it. What was new was documentation teaching the opposite, which is worse than none. All four now say what the branches do, including the asymmetry: a named vendor that turns out unusable is dropped, while the gateway branch binds whatever it is handed, because a gateway having no route for an id is not visible from there. The Provider Pin entry in CONTEXT.md also pointed at providers/pin.py, which the branch deletes, and collided by name with the Subsystem pin entry above it. It is now Configured provider, describing what agents.defaults.provider is: a word the user says, derived by nothing. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Four small things this branch owes, plus a file it should not carry. session.delete cleared the session's binding only when SessionManager returned True, and it returns False for a session that switched model before its first save -- so that entry leaked for the life of the process. Clearing what is not there costs nothing. providers/lazy.py carried a production docstring naming AgentLoop.refresh_context_window, which this branch deletes. There is no setter of on_built left in raven/, and nothing to correct after the fact now that the window resolves on first read. Onboarding still wrote provider="auto" when clearing a removed provider's default. That sentinel is retired, "" is the cleared spelling, and the migration that would have rewritten it never comes back to a config already stamped at the current generation. ui-tui/package-lock.json is reverted to main: its only change was libc platform metadata on optional dev dependencies, npm-version drift from one machine rather than a dependency change. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
d7bd1c6 to
98115e1
Compare
Resolving the conflict with the merged #346 by keeping both sides left the two test groups touching. Formatting only. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
98115e1 to
2148b1f
Compare
|
Rebased onto One conflict, and it was additive. Two commits are mine cleaning up after that resolution, both trivial and both worth naming so they are not mistaken for content: Re-verified on the new base, not carried over from the last run: No finding from either review round was re-opened by the rebase: the migration floors, the capability-cache invalidation, the @gloryfromca -- you asked that this not merge until the migration watermark was per-migration, and later added R2 and R4 to that list. All three are in, each reproduced before being fixed and each mutation-checked (reverting the fix turns the new tests red). A re-look when you have time would be welcome; the PR still needs one approval to become mergeable. |
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned. Nothing new found -- this revision is the d7bd1c67 work replayed onto a main that gained #346, plus two blank lines, and I checked the seam rather than assuming it was clean.
One orientation note, because the PR still shows a red commit messages run: the head I was handed was 98115e15, whose style(test): header the repo's own linter rejects (style is not in ALLOWED_TYPES, and not in AGENTS.md's table). The pushed head 2148b1fb amends it to test(config): with a byte-identical tree, and scripts/check_commit_messages.py github/main..2148b1fb is silent. Already resolved; the red run is on the superseded sha.
What the rebase could have broken, and did not
#346 lands in exactly the code my R1 was about, so this was the whole job:
- R1 survives. Re-ran the reproduction on this tree: a gen-1 stamp with a hand-restored
contextWindowTokens: 65536keeps it in memory and on disk, gets stamped to 2, and prints no notice; a gen-1 config still sayingprovider: autostill picks up gen 2 (-> openrouter). The per-migration floors and both docstrings came through the replay intact. - The
test_config_loader.pyconflict was resolved without losing a side. 40 tests, no duplicate names, both of #346's (test_save_config_writes_only_what_differs_from_the_defaults,test_save_config_keeps_every_value_the_user_chose) present, and all four of the branch's migration tests present with the literal{"version": 1}preconditions still literal -- which was the half of my note that mattered, so the replay did not quietly reintroduce the constant. - #346's three changes do not collide with this branch. The new
_migrate_auto_providernotice goes through the same_migration_notices/drain_migration_noticespath #346 moved to stderr, so it inherits that fix rather than reopening it.save_config's new write-only-non-defaults cannot drop a key throughconfig.set model: that path uses its own raw read-modify-write (methods/config.py:148-168), notloader.save_config. And main's new doctor Config section callsconfig.get_provider_name(), which on this branch answers'anthropic'for an empty provider with a matching key,Nonewhen nothing serves the model, and the explicit name for a passthrough vendor --Nonebeing what doctor already reports as "could not be routed to any configured provider", which is the check #346 deferred to this PR and which reads correctly here even unwritten.
Test state, measured on this tree
TERM=dumb uv run pytest tests/ 6700 passed, 33 skipped, 13 deselected, 0 failed
uv run ruff check / format clean, 832 files formatted
npx vitest run (ui-tui) 993 passed (86 files)
npx tsc --noEmit clean
npm run lint:rpc generated.ts in sync
check_commit_messages.py silent over github/main..2148b1fb
Still open, unchanged and not re-raised
The scope prop note from my last pass (modelPicker.test.tsx:179 no longer typechecks, and useMainApp.onModelSelect has no test) is untouched here. Still nonblocking, still the two lines it was.
mergeStateStatus is BLOCKED only because the ruleset wants one approving review and every review on this PR is COMMENTED -- nothing left in the code as far as I can see.
The automated review caught this: `modelPicker.test.tsx` renders `<ModelPicker>` without the `scope` prop this branch made required, so the component under test was mounted with `scope` undefined and the `scope === 'default'` footer had no coverage at all. It compiled because `tsconfig.json` excludes `src/__tests__`, which is why `tsc --noEmit` was clean and said nothing -- the one file that would have flagged the missing prop is outside the check. Asserted on `--default sets` rather than the whole footer sentence: ink emits CSI sequences mid-word and the harness's `normalize` turns each into a space, so a long phrase does not survive the frame intact. The two frames are also compared to each other, which pins that the prop is what changes the line rather than that either string is spelled a particular way. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The last open note from both review rounds: `useMainApp.onModelSelect` had no test, and it is the callback that carries `--default` back out of the picker. That flag going missing there is the defect this branch fixed -- `/model --default` opened the picker, the selection returned without it, and the switch was session-scoped while the transcript reported success. Testing it in place would mean mounting the whole app for one string, so the rule is its own exported function, `modelSelectCommand`. It is a rule worth naming rather than an extraction for the test's sake: what a selection becomes on the command line has to satisfy three separate constraints in `parseModelArg` at once -- the flag form, the refusal of a bare id, and `--default` being stripped in any position before parsing. Four cases, including both directions of the scope. Adding the flag unasked is the mirror defect and is asserted against: it would move the default for every new session because someone picked a model once. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned. One commit since 2148b1fb, test-only, and it closes the concrete half of the last note I had open.
git diff 2148b1fb..229a98f3 -- ':!*__tests__*' is empty, so production code is byte-identical to the revision I cleared last round. That leaves one thing to check, and I checked it rather than reading the commit message:
- The type error is gone.
tscovermodelPicker.test.tsxdirectly reports zeroTS2741; the mount helper now takesscopeand defaults it to'session', so every existing case keeps the behaviour it was written against. - The new case is not idle. Replacing the footer's
scope === 'default'withfalse-- the mutation that reintroduces exactly the missing coverage -- turnssays which scope the selection will applyred. It discriminates. - Everything still green on this head:
TERM=dumb pytest tests/6700 passed / 0 failed,ruff checkandformat --checkclean,npx vitest run994 passed (86 files, one more than last round),npx tsc --noEmitclean,lint:rpcin sync,eslint src/0 errors.check_commit_messages.py github/main..HEADis silent, andtest(tui)is an established scope onmain(11 commits, two of themtest(tui)).
Two remarks on the shape of the test, neither worth acting on: expect(defaultFrame).not.toContain('--default sets') is a negative assertion, and a positive one on the default footer would fail closed if that branch ever rendered nothing -- the not.toBe(sessionFrame) line covers most of that, and the commit says why the long phrase cannot be matched intact. And the softer half of my note stands: useMainApp.onModelSelect, the line that appends --default to the shelled command, still has no test. Both are suggestions, neither is new, and I am not re-raising either.
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned, and with this commit I have nothing left open on it.
One commit since 229a98f3, closing the second half of my last note. It touches production code, so I checked the extraction rather than just the new test.
The refactor is behaviour-preserving. modelSelectCommand(model, slug, pending) computes pending === 'default' ? ' --default' : '', which is the same predicate the inline asDefault used, and the call still happens before patchOverlayState({ modelPicker: false }) -- the ordering R4's fix depends on. Identical output for all three overlay values.
The command it builds actually survives the parser, which is the coupling the test only asserts by proxy. Running session.ts's own two regexes over the produced string:
/model anthropic/claude-haiku-4-5 --provider openrouter --default
asDefault = true
parsed = { provider: 'openrouter', value: 'anthropic/claude-haiku-4-5' }
So the flag form, the strip-before-parse, and the refusal of a bare id are all satisfied at once. And the two halves join up: the new unit test pins the string, while createSlashHandler.test.ts already pins that this spelling puts scope: 'default' on the wire.
The new test discriminates. Dropping the --default from modelSelectCommand -- the original defect exactly -- turns two of its four cases red.
One remaining check, since an extraction can leave a second copy to drift: modelSelectCommand is the only place in ui-tui/src that builds an executable /model command. The other /model strings are user-facing advice (the refusal at session.ts:115-116, the picker footer at modelPicker.tsx:1428), not commands the code runs.
Verification on this head
TERM=dumb uv run pytest tests/ 6700 passed, 33 skipped, 13 deselected, 0 failed
uv run ruff check / format clean, 832 files formatted
npx vitest run (ui-tui) 998 passed
npx tsc --noEmit clean
npm run lint:rpc generated.ts in sync
npx eslint src/ 0 errors, 22 pre-existing warnings
check_commit_messages.py silent over github/main..HEAD
Both halves of the note I had open are now closed, each mutation-checked rather than taken on the commit message. Nothing new found on this revision.
|
Closing out the review threads, with where each was fixed on the current head (
Every one was reproduced before being fixed, and each fix was mutation-checked -- reverting it turns the new test red. Two things I did not do, both deliberate:
Follow-ups I raised earlier and left for their own issues -- the |
## Summary Six follow-ups from #284's review rounds. Five are defects in what that PR itself added or removed and were deliberately deferred rather than rushed into a diff already under review; the sixth is a check #346 wrote down as belonging here, because it only becomes a real state once the provider is explicit. **A session's model now survives a restart on every surface, not only in the TUI.** The read half of the persistence hung off `session.resume`, so only the surface that calls that handler got it: a conversation arriving from a channel came back on the configured default with its own choice sitting unread in its own session record. The read moves onto the loop, where every entry point already goes, and the handler's duplicate copy is gone -- one reader instead of two. `has_session_binding` restores first as well, or a session that switched before a restart reports as having inherited the default and the picker stars the wrong row. `clear_session_binding` counts as having consulted the record, so returning a session to the default is not undone by the next read. **`raven provider use` refuses an unroutable provider before it writes.** A typo exited 0 with the config already changed, then printed a credential warning suggesting `raven provider set <typo> --api-key ...` -- advice that would have created a section for a vendor nothing routes to. `provider set` already applied the right test before writing a section; it is exposed as `ensure_routable_provider` and called first, which is the order the TUI's `/model` already used. Only an unroutable *name* is refused: a known provider with no credentials yet is still written and reported, because picking a model before configuring its key is a normal order to do things in. "No spec of ours" cannot be the typo test either -- Raven carries none for mistral or xai, and refusing on it would block every passthrough vendor the config documents as supported. **The guard that keeps a model and its provider paired has a home again.** A whole-repo AST scan failing any `set_default_model` without `provider=`, and any raw `agents.defaults.model` write not paired with a provider write, lived in `tests/test_provider_pin.py` and was deleted with that file. Only half its message was obsolete; the rule it enforces became more central, and it is the only place a machine checks it. `test_provider_resolution_invariants.py` is where the other source guards of this kind already live. **The binding cache is keyed on everything it caches.** The fingerprint covered `config.providers`, but `bind` also copies `contextWindowTokens` onto the binding, so editing that number left a stale binding answering with the old one until a restart -- the "I changed it and nothing happened" shape the window ladder exists to prevent, arriving through the cache instead. **A gateway call is priced from the gateway's own table.** Who routes a request is not who bills for it. LiteLLM answered first for every id, including one naming a gateway, and the two disagree: measured on the pinned LiteLLM, `openrouter/z-ai/glm-4.6` is filed at 0.40/1.75 per million where OpenRouter's own current table says 0.50/2.00, so every such call was reported a fifth under its real cost. OpenRouter's catalogue now answers ahead of LiteLLM for ids that name it, fresh-only and never fetching: an expired copy is not better evidence than the router's table and would suppress the refetch the expiry exists to trigger, while a blocking fetch would be paid by the turn this runs after. Unchanged for a direct id, which is what once priced a self-hosted deployment at a hosted model's rate. **`raven doctor` can ask about a provider nothing resolved.** #346 deferred this with its reason: before the explicit-provider rule, the field defaulted to `auto` and blank never happened. Now blank means the load-time migration tried the derivation and had no answer. Two findings -- blank, and a name nothing routes to -- both report-only. Neither is offered to `--fix`, and that is not an omission: only the user knows which vendor they meant to pay, and the migration has already tried. Guessing again under the name `--fix` would be the same guess wearing more confidence. ## Type - [x] Fix - [ ] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification ``` pytest tests/ 6477 passed, 43 skipped, 13 deselected (TERM=dumb) ruff check / format --check clean, 832 files formatted npx tsc --noEmit (ui-tui) clean npm test (ui-tui) 998 passed (87 files) commitlint + check_commit_messages.py clean over github/main..HEAD npm run lint:rpc generated.ts in sync commitlint + check_commit_messages.py clean over github/main..HEAD ``` Every fix is mutation-checked rather than assumed -- reverting it turns the new test red. That is worth naming for two of them, where writing the test taught something the fix alone did not: - The doctor check's scope is narrower than it looks. With a configured vendor that serves the model, the migration fills the blank during `load_config` and there is nothing left to report, so the fixture has to be genuinely unresolvable -- which is exactly when the finding is real. - The first attempt at the pricing tier read the any-age cache and made a stale disk copy outrank a refetch. `test_expired_disk_triggers_refetch` caught it, which is why the tier is fresh-only. Three review rounds landed on this PR after it opened, each fixed and each verified rather than taken from the report: - **Blocking:** the doctor provider check read a leftover `auto` as an unroutable vendor name. That is the legacy state the check exists for -- `_migrate_auto_provider` leaves the literal in place when it cannot resolve one -- so the user was told to fix a typo they did not make. It now matches `Config._match_provider`'s own `forced != "auto"` test, and says the word is retired rather than merely ignoring it. Neither neighbouring test used a literal `auto`, which is how the branch between them went unentered. - `_restore_attempted.add` in `clear_session_binding` was inert, and its stated reason did not hold for the only caller: `session.delete` unlinks the record before the clear runs. Dropped rather than relocated -- marking in `set_session_binding` would claim the record had been consulted when it had not. The test that was supposed to guard it is renamed and its docstring now scopes what it does and does not pin. - `_routes_anywhere` caught `Exception` where the sibling check catches `KeyError`, and the new refusal printed a different failure marker from the eleven others in its file. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk Two user-visible changes, both deliberate: `raven provider use` now exits 1 on a provider name nothing routes to, where it used to exit 0 with the config already written. A script passing a misspelled `--provider` starts failing; a script passing a real one is unaffected, including for a passthrough vendor. Cost figures change for calls made through OpenRouter on ids where its catalogue and LiteLLM's copy disagree. The new number is the one the gateway bills, so this corrects a systematic under-report rather than introducing a new figure -- but a user comparing today's total to yesterday's will see the step. Rollback: revert the commits. Nothing here writes a new field or changes a stored shape, so a revert needs no migration. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A -- follow-ups from the review of #284, which is merged. --------- Co-authored-by: arelchan <204152633+arelchan@users.noreply.github.com> Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Summary
The model was two attributes on a process-wide
AgentLoop, so there was one answer for everyone: two sessions could not run different models, a switch in one moved all of them, and the "default" was whatever the last switch happened to write toagents.defaults. This makes the model a property of the conversation, and -- following the same thread down -- makes the credential that serves it something the user says rather than something Raven infers.A
ModelBindingis a model id, the credential that serves it, and how much that model can hold, as one value.ProviderPoolis the single place deciding which credential a model id pairs with, cached per (vendor, model).run_turnresolves the binding for the turn's session and holds it in aContextVarfor the whole turn tree; the loop'sprovider/model/context_window_tokens, the context engine's LLM-backed segments, the skill gate and rewriter, and the consolidator all read that instead of a reference of their own.agents.defaultsDetached work inherits the context copy
asynciomakes at task creation, so a subagent finishes on the model it was spawned under.The window belongs to the binding
Sizing came to a head during the rebase.
refresh_context_windowand its cascade assumed the loop has one current model -- which this PR makes false, since two sessions can be on a 200k and a 1M model at once, and one int on the loop cannot answer for both. That is the same mis-sizing #341 removed, arriving through another door.So the window moved onto
ModelBinding, beside the credential, and every holder reads the binding of the turn it is running under.refresh_context_windowis gone: a turn now enters on a binding that already knows its own size, so there is nothing to refresh.It resolves on first read rather than at construction, because building a provider is what imports LiteLLM -- a lazily built one has not done it yet, so an eager window is the catalogue-miss default for every model, which is the defect
on_builtexisted to paper over. A miss is deliberately not cached, so a cold read cannot make the process wrong for its lifetime. An explicitcontextWindowTokenstravels on every binding, so a pinned number survives whatever model a session switches to.The provider is a word the user says
A model id does not name whose credential serves it.
openrouterservinganthropic/claude-haiku-4-5andanthropicservingclaude-haiku-4-5are both real, name different keys, bill different accounts, and look identical on the wire. Everything that used to fill in the blank was guessing which account pays.agents.defaults.provider: "auto"was the same guess at the config layer, and it did not detect anything: a prefixed id was answered by the provider it names, but a bare id fell to keyword matching inPROVIDERSorder and took the first configured claimant -- so with anthropic and openrouter both keyed,gpt-4.1went to openrouter over openai because openrouter sits third in that list and openai eighth./model <id>is refused, and the refusal names the two ways out:/modelfor the picker, or/model <provider> <id>. A prefixed id is refused too -- a prefix is LiteLLM routing syntax, not evidence about a credential.config.set modelrefuses without a provider, which is the boundary where the rule can be enforced rather than asked politely.raven provider userequires--provider, and names it alongsideraven provider listin the error.--defaultis not an exception: changing what new sessions start on is the same choice about the same thing.agents.defaults.providercarries no default, and a one-shot migration resolves each pre-rule config through the old derivation and writes the answer into it. Behaviour is unchanged by construction -- the value written is what that config was already getting -- but it stops being inferred. It reuses the sidecar watermark from fix(*): stop an old default from capping every model's context window #341, with a floor per migration rather than one shared mark, so raising the generation for this one does not re-open the previous one. A config the derivation cannot answer for is left blank rather than filled with a vendor chosen to have something there.The picker already asked in this order -- provider, then model, with a free-text row for an id no catalogue lists -- so it needed nothing. Both help strings now carry the usage, because a rule the user meets first as an error is a rule we chose not to tell them.
With nothing left deriving,
providers/pin.pyis deleted: its whole subject was "which provider to pin when the user changed the model without naming one", and its last caller was the onboarding wizard, which asks for the provider before the model and therefore always had one in hand.Config defaults
No subsystem ships a vendor default.
context.curator_model,token_wise.tool_result_lifecycle.summary_modelandskill_forge.detect_modelall hardcoded the same Gemini id, andtoken_wise.smart_routing.tiersshipped six models across three vendors -- for users who may hold no key for any of them. All are unset now, which is what "not configured" has to mean for the subsystem rule to be expressible.context.curator_modelandskill_forge.llm_gate_modeltook a model id and nothing else, so the pool had to guess which credential served it. Each pin now takes a provider alongside the model (curator_provider,llm_gate_provider). With the provider named, a vendor whose credentials are unusable is logged and dropped rather than silently borrowing the conversation's key. With it unset the pin still binds: a configured gateway takes it, because a gateway serves whatever id it is handed under its own credential, and only without one is the vendor derived from the id. That branch is unchanged from main and is documented as it behaves rather than as the rule would prefer -- an earlier revision of this description and ofCONTEXT.mdclaimed the drop applied there too, which was wrong.Release note. With
context.curator_modelunset, the Curator's slow path runs on the conversation's model instead of failing on a Gemini id nobody had a key for and dropping to the deterministic plan. That is the rule working as asked, but on a long conversation it is up to 12 tool-calling requests per turn of context housekeeping that previously cost nothing. Setcontext.curator_model(andcurator_provider) to a small model to keep it cheap.Review findings
Round 1 (pre-rebase), five findings. A pin naming a vendor with no spec crashed the agent at startup -- fixed on main, which now passes the provider name separately instead of reading it off a spec that may be None; verified with the reporter's own repro.
/modelbefore the first message no longer manufactures a zero-message session record (session.createis lazy, andsession.titleguards the identical case the same way). Every rpc call in the TUI's session commands has a.catch-- without it,/modeltyped before the firstsession.createresolves wrote a raw stderr line over the Ink render while the transcript showed nothing. The remaining two are the picker's provider row and the gateway pin; see below.Round 2, three before-merge findings, all reproduced here before being fixed.
contextWindowTokens: 65536a user had put back by hand after our own notice invited them to, and printing that invitation again. Each migration now has its own floor. The test that should have caught it built its precondition fromCURRENT_CONFIG_VERSION, so it moved with the bump; it now pins the literal a shipped build wrote, and two tests cover the upgrade-from-an-older-generation path in both directions._adopt_providertook its_image_tool_result_ok.clear()with it. Both capability caches key on a model id but are computed from the provider, so anapiBaserepointed at a box with different capabilities, or a re-authenticated provider, kept the old endpoint's verdict for the life of the process -- images silently dropped from tool results with nothing in the log. Both binding setters now clear them.--defaulthad no working path through the TUI. The picker footer taught/model <name> --default, which is the bare-id spelling the parser refuses; the refusal dropped the flag from the suggestion it told the user to follow; and/model --defaultopened the picker, whose selection sent a session-scoped switch that looked like it had changed the default. The flag now rides into the overlay state and back out through the picker's callback, the refusal keeps it, and the footer writes the spelling that works.The two carried from round 1 are fixed here rather than dissolved. An earlier revision of this description said the work had dissolved them; it had not, and both were still reproducible.
model.optionsre-derived the provider from the id. For a vendor with a spec that now reads the prefixstored_model_idwrites, butfind_by_modelanswers None for a passthrough vendor (mistral, xai), so the picker starredagents.defaults.providerfor a session running on someone else's key -- and the starred row is exactly what a user reads to answer whose key is paying. It now reads the head of the stored id, and stars nothing when that names no configured provider.Round 3, after the rebase onto a main that gained #346. The conflict was additive and is
described in the thread; two follow-on commits closed the two nonblocking notes that survived it.
modelPicker.test.tsxwas rendering<ModelPicker>without thescopeprop this branch maderequired, so the
scope === 'default'footer had no coverage -- andtsconfig.jsonexcludessrc/__tests__, which is whytsc --noEmitwas clean and silent about it. AndonModelSelect, thecallback that carries
--defaultback out of the picker, had no test; the rule it applies is now itsown function,
modelSelectCommand, because what a selection becomes on the command line has tosatisfy three separate constraints in
parseModelArgat once.Also taken, each a defect in what this PR itself added or removed:
clear_session_bindingwas gated onSessionManager.deletereturning True, which it does not for a session that switched model before its first save, so the entry leaked for the life of the process;_migrate_auto_providervalidated its probe against a strictConfigbefore the shims that relocate legacy top-level blocks, so a config still carryingskillRouterfailed the probe, was skipped silently, and was stamped anyway -- never retried, on exactly the oldest configs most likely to still sayauto;providers/lazy.pyandAgentLoop.set_providercarried docstrings naming methods and callers this PR deletes; onboarding still wroteprovider="auto"when clearing a removed provider's default; andui-tui/package-lock.jsonis reverted to main, its only change being npm-version drift inlibcmetadata.Type
Verification
tests/test_cli_theme.py::test_bold_accent_renders_styled_not_bareisTERM-dependent, notordering-dependent: it fails under an interactive
TERMand passes underTERM=dumb, which is whatthe
coveragetarget pins and what CI runs. That file is not touched by this PR.Every fix in rounds 2 and 3 was mutation-checked rather than assumed: reverting the migration floor,
the cache clear, the flag pass-through, the passthrough-vendor fallback, the picker's
scopepropand the
--defaultcommand-building each turns the corresponding new test red.Driven by hand in an isolated home against the built TUI bundle, not only by tests:
/model claude-opus-4-5refused with the two ways out;/model openrouter/anthropic/claude-opus-4-5refusedas well;
/model openrouter claude-opus-4-5applied;/modelopening the picker at the providerlevel. A real config carrying
provider: autoand a bareclaude-opus-4-5migrated toprovider: openrouter-- the vendor it was already resolving to -- with the notice printed and the watermarkwritten to the sidecar.
Risk
User-visible behaviour changes, all deliberate:
/model <id>,config.set modelwithout a provider, andraven provider usewithout--providernow fail where they used to guess. That is the point, but it is a workflow change for anyone who typed a bare id -- the errors name the fix, and the picker needs nothing new learned.agents.defaults.provideris written into every config that did not have one, on the first launch after this lands. The value is what that config was already resolving to, so no request changes vendor; what changes is that it is now visible and editable.With
curator_modelunset out of the box, the Curator's slow path starts actually running (see the release note above). Cost, not correctness.One contract tightening is not backward compatible on its own:
config.set key="model"now rejects a call with noprovider, and the-32009 model_switch_in_turncode is removed from the error table. The in-repo TUI moves with it, but any client outside this repo that sends a bare model id starts failing on the first request after this lands.Rollback: revert the commits. The config keeps an explicit
provider, which every published build accepts and treats exactly as it treats a hand-written one. Theconfig.migrations.jsonsidecar is left behind and every build since #341 does read it, so a reverted build sees{"version": 2}and treats the config as fully migrated -- which is correct, since it is. Delete the sidecar to force the migrations to be reconsidered.Related Issues
N/A