Skip to content

fix(*): stop an old default from capping every model's context window - #341

Merged
arelchan merged 2 commits into
mainfrom
fix/config_legacy_context_window_migration
Aug 17, 2026
Merged

fix(*): stop an old default from capping every model's context window#341
arelchan merged 2 commits into
mainfrom
fix/config_legacy_context_window_migration

Conversation

@arelchan

@arelchan arelchan commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Every config written by 0.1.10 or earlier carries contextWindowTokens: 65536 verbatim: that was the schema default back then, and the bootstrap dumped every default to disk. 0.1.11 (#287) started sizing the window from the model's own catalogue, but an explicit pin still wins over it -- correctly, that is what a pin means -- so the fix reached new installs only. Every upgraded install stayed capped at 64k on a 1M-token model, and /model could not shake it loose (refresh_context_window returns early for an explicit window, by design).

The blast radius is not cosmetic. The window sizes the per-turn history budget (_make_token_budget), the Curator's fast/slow path threshold (available_history * 0.60) and the memory consolidator's archive target (window // 2). An upgraded install therefore trimmed history early, paid for an extra Curator LLM call per turn, and consolidated at 32k instead of ~500k. The only existing signal was a logger.warning in a file nobody reads.

Key decisions:

  • Clear the pin once, under a watermark kept in a sidecar (config.migrations.json). The value carries no provenance -- 65536 we planted and 65536 a user chose are the same three bytes -- so a watermark is the only thing that lets a re-set value stand for good. Without it, a user re-setting 65536 would have it stripped on every load, which is worse than the bug. The watermark deliberately does not live in config.json: Config is extra='forbid' and load_config raises rather than falling back, so a key this build knows and an older one does not is a hard boot failure for the older build -- a reverted release, a pinned version, or a second checkout sharing one ~/.raven, which is already the case in this repo. A sidecar no published build reads is ignored instead of fatal. Because it is our file rather than the user's, it is also stamped unconditionally, which closes the one hole an in-file stamp had to accept: a contextWindowTokens the user sets by hand afterwards is never second-guessed, whatever its value.
  • Run it at config load, not in raven upgrade. The upgrade helper is the outgoing version and cannot know a rule that ships with the incoming one; it execves a bare Python bootstrap that never imports raven. And install.sh / uv tool upgrade never call raven upgrade at all. Config load is the one chokepoint every entry point shares, so the user experience is "upgraded, and it was already fixed".
  • A file we did not edit is left byte for byte alone. config.json is rewritten only where the fossil was actually removed -- provider use and friends promise not to touch a config they decided against changing, and that promise is theirs to keep, not ours to spend (test_use_leaves_the_config_alone_when_it_cannot_tell caught an earlier draft that stamped unconditionally in the config).
  • The rewrite carries the file mode across. os.replace swaps the inode, so a config tightened to 0600 -- it holds providers.*.apiKey -- came back 0644 in the first draft. The temp file is chmod-ed to the original's mode before the swap, and named per-PID so two processes migrating at once cannot have one truncate the file the other is about to move into place.
  • The write is surgical. It re-reads the raw file, because the mapping load_config migrated has the extension blocks popped (pop_extension_keys) and writing that back would delete the user's memory / plugins / skillForge sections. It never goes through save_config, which dumps ~8 KB of defaults and would re-plant exactly this kind of fossil. A read-only home degrades to an in-memory-only migration rather than failing the boot.
  • The user is told where the user is looking, from every entry point. The notice is drained in the run() wrapper, so all thirteen config-loading commands tell it, not the two that print eagerly (agent, gateway, which keep the early print because they hold the screen for a long time). This matters because the watermark suppresses the next run's notice: an unsaid notice is lost, not deferred, and raven status would otherwise migrate the file in silence forever. The TUI and the served page get a new config_notices field on the session init bundle, rendered as a system line on both session.create and session.resume -- resume drains the same one-shot list, so a picker-first user would otherwise see nothing. This also gives the client-side config_warning / credential_warning handlers their first server-side sibling that is actually populated -- both were dead wiring. The notice names the way back, because a window genuinely smaller than 65536 (a local model, an endpoint served with --max-model-len) is a real configuration this clears once.
  • The loop's 65536 warning is gone. Post-migration, a pin that reaches the constructor is a deliberate choice, and warning about deliberate choices is noise.

Follow-ups, deliberately not in this PR: raven doctor --fix so the check can be run on demand rather than only at the next launch, and stopping the bootstrap from materialising defaults at all (save_config(load_config()) in onboard) -- the root cause, and a change to every new user's config file shape, so it deserves its own review.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

pytest tests/test_config_loader.py                      31 passed (16 new migration cases, incl. the
                                                       sidecar watermark, mode preservation, per-PID temp)
pytest tests/test_tui_rpc_session_init_bundle.py         2 new cases, all green
pytest test_config_loader + test_config_schema + test_config_raven_loader \
     + test_cli_provider_commands + test_tui_rpc_session_init_bundle + test_cli_smoke
                                                       194 passed (re-run after rebasing on main)
pytest tests/ -k "config or agent_loop or provider or helpers or tui_rpc or usage_sink or onboard or gateway"
                                                       failure set identical with and without this branch
                                                       (56 -> 56, zero delta; pre-existing, a local config
                                                       block the schema on main does not accept)
ruff check raven/ tests/                               All checks passed
ruff format --check                                    already formatted
npm run type-check (ui-tui)                            clean
npx vitest run src/__tests__ (ui-tui)                  843 passed (70 files)
npx prettier --check / npx eslint on both changed .ts  0 errors (1 pre-existing warning at line 146)

End-to-end on a config shaped like an upgraded install (contextWindowTokens: 65536, plus memory and skillForge blocks):

first load    in-memory pin      -> None
              file               -> the line is gone, model/maxTokens/temperature untouched
              watermark          -> config.migrations.json, nothing added to config.json
              extension blocks   -> intact
              notice             -> surfaced once, then drained
user re-sets 65536 afterwards    -> honoured, no further notice
config with no fossil            -> file byte identical, no write
read-only home                   -> migration still correct in memory, no raise
0600 config with an apiKey        -> still 0600 after the migration
migrated config, older build     -> loads fine (the downgrade path the sidecar exists for)
raven status on a fossil config  -> prints the notice (it used to migrate in silence)
  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed

Risk

User-visible behaviour changes: on the first launch after this lands, an upgraded install loses the contextWindowTokens: 65536 line from ~/.raven/config.json and its context window jumps to whatever the model actually supports. Sessions get a longer history budget, the Curator drops back to its zero-LLM fast path far more often, and the status bar percentage falls accordingly. Users who genuinely need a 64k ceiling (a local model, a self-hosted endpoint with a smaller serving window) must re-add the line, which the notice tells them; those requests would fail loudly at the endpoint rather than silently, so the failure is self-announcing.

Rollback: revert the commits. Nothing of ours is left inside config.json, so an already-migrated config is byte-for-byte a config that simply has no pin -- exactly the state a fresh install has, loadable by every published build. The orphaned config.migrations.json sidecar is ignored by any build that does not look for it, and re-applying the change later just re-reads it. An earlier draft of this PR put the watermark in config.json and would have made a revert leave every migrated install unable to boot (Config is extra='forbid'); that is fixed, and the same reasoning covers pinning an older release or running a second checkout against the same ~/.raven.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

N/A

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Claude finished @arelchan's task in 5m 29s —— View job


Review of PR #341

Blocking: the new finally: print_config_migration_notices() in raven/cli/commands.py corrupts raven doctor --json / raven import --json output when a migration fires during that run. Everything raised in the earlier review round on this PR has been correctly fixed by the second commit — see below.

Prior blockers/nits, now resolved by commit 3156fc3:

  • Top-level configVersion stamp (hard boot failure on older builds / revert / shared ~/.raven) → moved to a sidecar config.migrations.json, invisible to any build that doesn't read it. Confirmed Config no longer carries the field and extra="forbid" no longer trips.
  • File mode dropped on migration write → os.chmod(tmp, path.stat().st_mode & 0o7777) before os.replace, covered by test_migration_preserves_the_config_file_mode.
  • Fixed temp-file name racing across processes → now PID-scoped (config.json.migrating.<pid>), covered by a test that spies on Path.write_text.
  • Notice only reaching agent/gateway → now drained from a finally in the run() wrapper, so every CLI command tells (see the new blocker below though).
  • session.resume swallowing config_noticesuseSessionLifecycle.ts's resumeById handler now drains it the same way newSession does, and SessionResumeResponse.info in gatewayTypes.ts was updated to declare the field.

New finding:

  • [blocker] raven/cli/commands.py:175-183 — the finally block runs unconditionally after every command, but raven doctor --json (doctor_commands.py:510-511) and raven import ... --json (import_commands.py:415-452) print machine-readable JSON to the same stdout console. doctor calls load_config() at doctor_commands.py:176, so the first --json run after an upgrade that still has the retired contextWindowTokens: 65536 pin appends a [yellow]Config updated: ...[/yellow] line right after the JSON blob, breaking jq/json.loads() on the captured output — exactly the "upgrade, then run a CI health check" case this PR targets. Before this commit the notice helper was only wired into agent/gateway (neither has JSON output), so this collision is new. The repo already routes non-interactive diagnostics to stderr elsewhere in this file (ConfigReadError handler, commands.py:171-173) and in loader.py's JSON-decode warning; routing print_config_migration_notices() to stderr would fix both --json paths without losing the notice for a human. Inline comment posted.
  • [nit] tests/test_config_loader.py:263 — stale comment still refers to a "configVersion stamp"; the watermark now lives in the .migrations.json sidecar. Comment-only, no behavior impact.

Also checked, no issues found: _migration_version/_write_migration_version error handling (malformed/unreadable sidecar degrades to generation 0, self-healing), the untouched-config-stays-byte-identical guarantee, raven/config/raven.py:1356's _migrate_config(pop_extension_keys=False) call correctly stays opted out of the run-once migration (run_stamped defaults False), removal of populate_by_name from Config's model_config (safe — no other field on Config itself carries an alias), and branch/commit-message conventions (scope, ASCII-only body, Co-authored-by trailer).

Branch: fix/config_legacy_context_window_migration

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: the top-level configVersion stamp makes every earlier raven build refuse to boot on a config this migration touched -- including the git revert rollback the description promises. The diagnosis and the core fix are right, and the tests are honest; two smaller findings below the blocker.

What holds up, so it is on the record:

  • The fix really does fix it. AgentDefaults.context_window_tokens is int | None = None (schema.py:267), so popping the key hands the window back to providers.rates; I confirmed end to end on a config shaped like an upgraded install.
  • The two design calls that look risky are the right ones. Re-reading the raw file in _persist_migrated_config instead of writing back the mapping load_config migrated is necessary -- pop_extension_keys has already removed memory / plugins / skillForge -- and test_migration_write_back_keeps_extension_blocks pins it. Avoiding save_config is likewise necessary; save_config does model_dump(by_alias=True) (loader.py:300), which is how the fossil got planted in the first place, and test_migration_write_back_does_not_materialise_defaults pins that too.
  • Tests are not weakened. 53 passed in tests/test_config_loader.py tests/test_tui_rpc_session_init_bundle.py, nothing skipped or xfailed, and each new case asserts observable state (on-disk bytes, drained notices) rather than "did not raise". The read-only-home case does chmod the directory rather than mock the failure.
  • Repo rules: branch fix/config_legacy_context_window_migration and the header both match sections 2 and 3, the Co-authored-by trailer is present, tests went into the existing files instead of new phase-suffixed ones (section 5.4), no assets (section 7).

Covered: the diff, AGENTS.md rules, callers of _migrate_config (including config/raven.py:1356, which is in-memory only and unaffected), the CLI entry points that load config, backward and forward compatibility, and whether the tests were weakened. I did not check the ui-tui side beyond reading the two changed files.

Comment thread raven/config/schema.py Outdated
# an absent key means "generation 0", so this default is free to name the
# current generation -- and must, so a config written fresh today is never
# taken for a pre-migration one.
config_version: int = Field(default=CURRENT_CONFIG_VERSION, alias="configVersion")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking. Config is extra="forbid" (line 1015) and load_config turns a ValidationError into a raised ValueError rather than falling back to defaults (loader.py:270-276). So a config carrying configVersion is not merely unrecognised by an older build -- it is a hard boot failure. Confirmed against github/main's schema:

$ # main's Config.model_validate({'configVersion': 1, ...})
OLD SCHEMA REJECTED: ValidationError
configVersion
  Extra inputs are not permitted [type=extra_forbidden, input_value=1, input_type=int]

Concrete failures:

  1. The rollback in the PR description does not work. git revert removes this field, and every install that had already launched once now fails at load with Config at ~/.raven/config.json fails schema validation. The description says those configs "stay migrated ... which is the same state a fresh install has" -- they do not, they carry a key the reverted schema rejects. Since the squash body is this description, that claim lands on main (section 3.7 asks Risk to state how to roll back).
  2. It fires inside the repo today, not just on a hypothetical downgrade: worktrees share ~/.raven/config.json. Run raven once on this branch, then raven status from a checkout of main, and it raises. Same for anyone who pins an older release.

Worth noting the escape hatch: Base (line 22) has no extra setting, so it defaults to ignore -- nested unknown keys are silently dropped by old and new builds alike. Stamping inside a Base block, or in a sidecar under ~/.raven/, would make old builds ignore the stamp instead of dying on it, and keeps a revert clean. If you would rather keep the top-level key, then the minimum is to say so in Risk: the revert must retain config_version in the schema, and downgrading to any published release will not boot.

Comment thread raven/config/loader.py Outdated

tmp = path.with_name(path.name + ".migrating")
try:
tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False), encoding="utf-8")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The write does not carry the file's mode across. write_text creates the temp file under the process umask and os.replace swaps the inode, so the mode of the original is gone -- which is the exact case config.paths.restrict_to_owner warns about ("a rewrite that replaces the file instead has to set the mode on the replacement itself"). config.json holds providers.*.apiKey (schema.py:364, 372).

Reproduced:

$ chmod 600 config.json     # {"agents": {...65536}, "providers": {"anthropic": {"apiKey": "sk-secret"}}}
-rw-------  config.json
$ python -c 'load_config(p)'
mode after migration: 0o644

A user who tightened their config to owner-only gets it widened to world-readable, silently, on the first launch after upgrading -- with the key still in it. os.chmod(tmp, path.stat().st_mode & 0o7777) before the os.replace fixes it (skip when the stat fails).

While in here, a smaller one on line 211: the temp name is fixed, so two processes migrating at once use the same path. The second one's write_text truncates the file the first is about to os.replace into place, which can leave config.json momentarily empty for a concurrent reader (that reader boots on defaults with the "not valid JSON" warning). The content does end up whole, so this is not the data loss the fixed name suggests, but path.name + f".migrating.{os.getpid()}" removes the window for free.

Comment thread raven/cli/_helpers.py
)


def print_config_migration_notices() -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Only run and gateway call this, but load_config() migrates and rewrites the file from many more commands: status_commands.py:27, doctor_commands.py:172, cron_commands.py:80, provider_commands.py:375, channel_commands.py:439, skill_commands.py:41, sandbox_commands.py:48, import_commands.py:110.

Because the notice is keyed to the migration and the migration stamps the file, the telling is not deferred -- it is lost. If the first raven command a user happens to run after upgrading is raven status (or provider list, or cron list), the config is edited, configVersion: 1 is stamped, nothing is printed, and no later run will ever mention it: the next load sees the stamp and produces no notice.

That is the case the notice exists for. The user who genuinely needs a 64k ceiling (local model, endpoint with --max-model-len) loses the line and the explanation together. Calling this from load_runtime_config, or from the run() wrapper after the command returns, would cover every command instead of two. The TUI path is fine -- tui_commands.py:448 loads in the same process that serves RPC, so _default_session_info drains it.

}

// Not a warning: the backend already migrated the config line it names.
for (const notice of info?.config_notices ?? []) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This drains and displays config_notices for session.create, but resumeById's .then(raw => ...) handler further down in this file (around where it reads r.info) never checks r.info?.config_notices (or config_warning/credential_warning). On the backend, session.resume also calls _default_session_info (raven/tui_rpc/methods/session.py:310), which drains the module-level _migration_notices list — a one-shot operation.

So a user whose first RPC call after an upgrade is session.resume (resuming from the picker, or raven tui --resume, before ever calling session.create) has the contextWindowTokens fossil silently stripped from ~/.raven/config.json with no notice shown at all — and it can never surface later in that process, since this drain already consumed and discarded it. That undercuts the PR's stated goal ("the user is told where the user is looking").

Also note SessionResumeResponse.info (ui-tui/src/gatewayTypes.ts:127) doesn't declare config_notices, so the resume path's type doesn't even model this field.

Fix this →

Comment thread raven/cli/_helpers.py
)


def print_config_migration_notices() -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] print_config_migration_notices() is only wired into agent_commands.py and gateway_commands.py. Other commands that call load_config()/load_runtime_config() directly — e.g. raven status (raven/cli/status_commands.py:27) and raven doctor (raven/cli/doctor_commands.py:172), both explicitly named in this file's own comments as reload-heavy callers — will silently migrate and persist the configVersion stamp (removing the fossil from the user's config.json) with no notice at all. This mirrors the pre-existing print_deprecated_memory_window_notice wiring (also limited to these two commands), so it may be accepted scope, but unlike that notice this one now has a real disk-write side effect attached to a previously pure-read load_config(). Worth a conscious call on whether status/doctor should also surface it, or whether that's left for the follow-up raven doctor --fix work mentioned in the PR description.

arelchan and others added 2 commits August 17, 2026 20:49
Configs written by 0.1.10 and earlier carry `contextWindowTokens: 65536`
verbatim: back then that was the schema default, and the bootstrap dumped
every default to disk. Since 0.1.11 the window is sized from the model's
own catalogue, but an explicit pin still wins -- correctly, that is what a
pin means -- so every upgraded install stayed capped at 64k no matter which
model it ran, and `/model` could not shake it loose.

The blast radius was never only cosmetic. The window sizes the per-turn
history budget, the Curator's fast/slow path threshold and the memory
consolidator's archive target, so an upgraded install trimmed history,
paid for an extra LLM call per turn and consolidated far earlier than the
model required. The single existing signal was a log line nobody reads.

Clear the pin where it lives, once, under a new `configVersion` stamp: the
value carries no provenance (65536 we planted and 65536 a user chose are
the same three bytes), so the stamp is what lets a re-set value stand for
good afterwards. It runs at config load rather than inside `raven upgrade`
-- the upgrade helper is the outgoing version and cannot know a rule that
ships with the new one, and install.sh / uv tool upgrade never call it at
all, while every entry point loads the config.

A file we did not edit is left byte for byte alone: the stamp lands only
where something was actually removed, which keeps the promise commands
like `provider use` make about not touching a config they decided against
changing. The write itself re-reads the raw file (the mapping load_config
migrated has the extension blocks popped, so writing it back would delete
the user's memory / plugins / skillForge sections) and never goes through
save_config (which dumps ~8 KB of defaults, re-planting this very kind of
fossil). A read-only home degrades to an in-memory-only migration.

The user is told in the terminal for CLI runs, and through a new
`config_notices` field on the session init bundle for the TUI and the
served page -- which also gives the client-side `config_warning` /
`credential_warning` handlers their first server-side sibling that is
actually populated. The notice names the way back, because a window
genuinely smaller than 65536 (a local model, an endpoint served with
--max-model-len) is a real configuration that this clears once.

Drop the loop's 65536 warning: post-migration, a pin that reaches the
constructor is a deliberate choice, and warning about those is noise.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Review found the stamp itself was the worse bug. `Config` is
`extra='forbid'` and `load_config` raises instead of falling back, so
`configVersion` in config.json is a hard boot failure for any build that
does not know the key: a reverted release, a pinned older version, or --
already true in this repo -- a second checkout sharing one ~/.raven.
Confirmed against a build without the field: `Config at ... fails schema
validation`. The rollback the PR description promised did not work either,
since a revert would leave every already-migrated config unloadable.

Move the watermark to a sidecar (`config.migrations.json`, sibling of the
config it describes so a --config path gets its own). No published build
reads it, so old builds ignore it instead of dying on it, and reverting
this change leaves nothing behind that breaks.

The sidecar is ours rather than the user's, which also closes the hole the
in-file stamp had to live with: stamp unconditionally, and a
`contextWindowTokens` the user sets by hand afterwards is never
second-guessed whatever its value -- while config.json is still only
touched when the fossil is actually removed.

Two more real defects from the same review:

- The replace dropped the file mode. `os.replace` swaps the inode, so a
  config tightened to 0600 (it holds providers.*.apiKey) came back 0644.
  Reproduced, then fixed by chmod-ing the temp file to the original's mode
  before the swap; `config.paths.restrict_to_owner` documents this as the
  replacing writer's job.
- The temp path was fixed, so two processes migrating at once shared it and
  the second's truncating write could be read as an empty config.json.
  Now per-PID.

And the notice reached two commands out of thirteen that load the config.
Because the watermark suppresses the next run's notice, an unsaid notice
was lost, not deferred: `raven status` (or `provider list`, or `cron list`)
would migrate the file silently and no later run could ever mention it --
exactly failing the user who needs a 64k ceiling. Drain from the `run()`
wrapper so every command tells, keeping the eager telling in `agent` and
`gateway` where it lands before a long-running command takes the screen.

Same one-shot drain made `session.resume` swallow the notice for anyone
resuming before creating a session (the picker, `--resume`); the resume
handler now renders it like the create handler does.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
@arelchan
arelchan force-pushed the fix/config_legacy_context_window_migration branch from bcd8f78 to 3156fc3 Compare August 17, 2026 12:49
@arelchan

Copy link
Copy Markdown
Contributor Author

All four findings reproduced and fixed in 3156fc3 (rebased on latest main). Nothing was waved off -- the first one was the more serious bug of the two in this PR.

The stamp in config.json (blocking). Confirmed exactly as described: a build without the field raises Config at ... fails schema validation, and the rollback claim in the description was simply false. The cross-checkout case is not hypothetical here either -- this repo has a dozen worktrees against one ~/.raven.

Took the sidecar option: config.migrations.json, sibling of the config it describes so a --config path or a second instance gets its own. Verified the downgrade path directly -- migrate on this branch, then load the same file from a checkout without the change: loads fine.

The suggestion turned out to be strictly better than what it replaced, not just safer. Because the sidecar is ours rather than the user's, it can be stamped unconditionally, which closes the hole the in-file stamp had to accept: previously a user hand-setting 65536 on a never-stamped config had it cleared once. Now config.json is touched only when the fossil is actually removed, and a value the user sets afterwards is never second-guessed. The PR description's Risk section is rewritten accordingly, since the squash body is that description.

File mode (blocking in effect). Reproduced: 0600 -> 0644 with the apiKey still in the file. Fixed by chmod-ing the temp file to the original's mode before the swap, with the stat failure skipped as suggested. restrict_to_owner's docstring was the right pointer -- a replacing writer owns the mode.

Took the per-PID temp name too. Agreed it is not data loss, but the window costs nothing to remove.

Notice wired to two commands out of thirteen. The framing is what made this land: the telling is lost, not deferred, because the watermark leaves the next load with nothing to report. raven status migrating in silence forever is precisely the user this notice exists for.

Drained from the run() wrapper, so every command tells it. Kept the eager print in agent and gateway -- they hold the screen for a long time, so the notice is better placed before they start than after they exit; the drain makes double-printing impossible either way. Verified with raven status on a fossil config: it now prints.

session.resume swallowing the notice. Correct, and the picker-first flow is the common case, not the exotic one. The resume handler now renders config_notices like the create handler, and SessionResumeResponse.info types it. Left config_warning / credential_warning on resume alone -- both are create-only fields today with no producer, so wiring them there would be dead code in a PR that is already wide.

Full gates after the rebase: 194 passed across the config / provider / RPC / smoke files, 31 in test_config_loader.py (16 migration cases now, including the sidecar, the mode and the temp path), zero new failures against a stashed baseline on the same selection, ruff clean, ui-tui type-check clean, 843 vitest passing.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned. Re-reviewed the new head (3156fc3c, rebased on 7bd02804 + the watermark commit). All three findings from my last pass are fixed, and fixed at the root rather than papered over.

Verified rather than taken on trust, on a config shaped like an upgraded install (fossil + providers.anthropic.apiKey + a memory block, chmod 600):

window  -> None                       # pin gone, sized from the model
mode    -> 0o600                      # preserved across the os.replace
file    -> {"agents": {"defaults": {"model": "x/y"}}, "providers": {...}, "memory": {...}}
sidecar -> ['config.json', 'config.migrations.json']
notice  -> 1
OLD BUILD SCHEMA: accepts migrated config     # main's Config.model_validate, the case that used to raise
second load pin -> None               # idempotent

On each finding:

  • The stamp moved out of the config entirely (_stamp_path sidecar) instead of just documenting the revert hazard. That is the stronger fix: nothing this build knows lands in a file an older build validates with extra="forbid", so a revert, a pinned release and a second checkout sharing ~/.raven all keep booting -- confirmed above against main's schema. It also closes the hole the old design accepted: the watermark is now written even when nothing needed removing, so a hand-set 65536 is never second-guessed even once. run_stamped keeping the extension loader (config/raven.py:1356) out of the stamped path is right -- it only reads blocks, and the base config comes from load_config.
  • Mode is carried over before the replace, with the stat failure swallowed. Matches what config.paths.restrict_to_owner asks of a replacing writer.
  • The notice now covers every command via the finally in commands.run(), which wraps app(), so status / provider list / cron list all announce a migration they performed. agent and gateway drain first, so there is no double print. The PID-scoped temp name is in too, and _persist_migrations returns before stamping when the config write fails, so a read-only home retries instead of recording a migration that never happened.
  • The bot's session.resume point is handled as well -- resuming first no longer swallows the notices.

Tests: tests/test_config_loader.py tests/test_config_raven_sections.py tests/test_cli_smoke.py tests/test_cli_helpers.py tests/test_tui_rpc_session_init_bundle.py -> 146 passed, 0 skipped; ruff check raven/ tests/ clean. The new cases assert observable state (on-disk bytes, sidecar contents, st_mode, the temp file's name) and the read-only case chmods a real directory rather than mocking the failure.

One nit, comments only, no behaviour: the test section header still says the pin is cleared "under a configVersion stamp", and test_config_without_the_fossil_is_left_byte_identical's docstring still argues "the stamp lands only on a file we actually edited" -- both describe the previous design, and the second is now contradicted by the test right below it. Worth a sweep so the next person bumping CURRENT_CONFIG_VERSION reads the mechanism that exists.

I checked the symlinked-config case (os.replace detaches a ~/.raven/config.json symlinked into dotfiles, leaving the tracked file behind with the fossil) and am not raising it: config/update.py::_write_atomic on main already writes that way, so it is the repo's existing convention rather than something this diff introduces -- and this writer is the one that improved on it by keeping the mode.

Covered this round: the delta since aa56f237, the new sidecar mechanism and its failure paths (unwritable stamp, malformed stamp, race, --config siblings), callers of _migrate_config, forward compatibility against main's schema, the CLI wiring for every command that loads a config, and whether the new tests actually exercise it.

Comment thread raven/cli/commands.py
# So this is the catch-all for every other command.
from raven.cli._helpers import print_config_migration_notices

print_config_migration_notices()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This finally block now runs print_config_migration_notices() after every command, including the two that promise machine-readable stdout: raven doctor --json (doctor_commands.py:510-511, console.print_json(...)) and raven import ... --json (import_commands.py:415-452).

Both are documented for automation (doctor's own help text: "Emit machine-readable JSON (CI-friendly)"). doctor calls load_config() at doctor_commands.py:176, so on the first raven doctor --json run after an upgrade that still carries the retired contextWindowTokens: 65536 pin, the migration fires, a notice gets queued, console.print_json(...) writes the JSON, and this finally then appends [yellow]Config updated: ...[/yellow] on the same stdout stream right after it — json.loads()/jq on the captured output now fails on trailing content. That's exactly the "upgrade, then run a CI health check" scenario this PR is built around.

Before this commit, print_config_migration_notices() was only wired into agent and gateway (neither has a --json mode), so this collision is newly introduced by moving the call into the generic run() wrapper.

Since the repo already routes non-interactive diagnostics to stderr elsewhere in this same file (the ConfigReadError handler at commands.py:171-173 uses Console(stderr=True)) and in loader.py's JSON-decode warning, printing config-migration notices to stderr instead of stdout would fix both --json paths without losing the notice for a human running the same command.

Fix this →

# Pre-0.1.11 bootstraps dumped every schema default to disk, and back then
# ``contextWindowTokens`` defaulted to 65536. A pin outranks the model's real
# window by design, so on upgraded installs that fossil silently caps every
# model at 64k. It is cleared once, under a ``configVersion`` stamp -- the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This section comment still says "cleared once, under a configVersion stamp" — stale after this PR moved the watermark to the .migrations.json sidecar (_stamp_path). Doesn't affect behavior, just worth a wording pass.

@arelchan

Copy link
Copy Markdown
Contributor Author

Both second-round findings are accepted; merging this as-is and taking them in the immediate follow-up rather than re-opening an approved PR for them.

The --json blocker is real and reproduced. Confirmed on raven doctor --json with a fossil-carrying config: the notice lands on stdout after the JSON and jq fails to parse the result. Introduced by this PR's own run() catch-all, and doctor's help does advertise the output as CI-friendly.

The fix is to print the notices on stderr, which is where the rest of this class of message already goes -- run()'s own ConfigReadError branch uses Console(stderr=True), and the loader's malformed-config warning prints to stderr too. That makes it correct for import --json and any future --json command without either having to know about it, which is better than allow-listing commands.

Scope note: it misfires only on the single first run after an upgrade, and only for a config that still carries the pin, so the exposure on main is one run per affected automation rather than an ongoing break. Weighed against holding an approved fix for a 64k cap that is degrading every upgraded install today, landing now and correcting the stderr target next is the better trade -- the follow-up PR (raven doctor --fix) touches these same two files.

Stale test section comment: correct, the wording predates the move to the sidecar. Same follow-up.

Thanks for both rounds -- the watermark placement in particular was the difference between a fix and a footgun.

@arelchan
arelchan merged commit 6c99ad5 into main Aug 17, 2026
19 checks passed
@arelchan
arelchan deleted the fix/config_legacy_context_window_migration branch August 17, 2026 12:59
@gloryfromca

Copy link
Copy Markdown
Contributor

Withdrawing my sign-off on 3156fc3c. The --json finding is real, and it is in the mechanism I explicitly endorsed in my last summary ("the notice now covers every command via the finally in commands.run()") -- I checked that every command reaches the drain and did not check what the drain writes to. My miss.

Reproduced on this head, fake HOME with a fossil-carrying config:

$ raven doctor --json 2>/dev/null > out.json ; jq -e . out.json
jq: PARSE FAILED
$ tail -4 out.json
Config updated: Removed the leftover `contextWindowTokens: 65536` from your
config (an old default, written there by earlier versions). ...

The config was migrated by that same run, so the corruption and the migration are the same event -- once per install, on the command whose help says "CI-friendly".

One thing not yet said, and the reason a green suite told nobody: nothing in the suite drives run(). Every CLI test imports app (or a sub-app) and goes through CliRunner -- test_cli_doctor_commands.py, test_cli_import_commands.py, all of them. The finally hook at commands.py:183 is therefore executed by zero tests, so the 194-passing selection could not have caught this and will not catch the next thing that goes in there. Whatever the fix, it wants a test that actually invokes run() with a fossil config and asserts stdout stays parseable; otherwise the follow-up lands as untested as this did.

On merge-now-versus-follow-up, my position is that it belongs here. It is one line (Console(stderr=True) for the notice) inside a line this PR itself added, the finding is already agreed, and stderr is the right channel on its own merits -- an advisory about a file we rewrote is not part of a command's output. Deferring it means main carries a known stdout regression plus an uncovered hook, and costs a second PR to undo, which is more work than the fix.

Your call and the approver's, not mine to make -- but I would rather not be the sign-off that carried it in.

arelchan added a commit that referenced this pull request Aug 18, 2026
…#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>
arelchan added a commit that referenced this pull request Aug 19, 2026
## 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 to `agents.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 `ModelBinding` is a model id, the credential that serves it, and how
much that model can hold, as one value. `ProviderPool` is the single
place deciding which credential a model id pairs with, cached per
(vendor, model). `run_turn` resolves the binding for the turn's session
and holds it in a `ContextVar` for the whole turn tree; the loop's
`provider` / `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.

| Rule | How it lands |
|---|---|
| Different sessions, different models | A dict of overrides, read once
at turn entry |
| A switch moves only the session that asked | Nothing else reads that
session's entry |
| A new session starts on the configured default | A session-scoped
switch does not write `agents.defaults` |
| A configured subsystem uses its own model+credentials; otherwise it
follows the conversation | The factory resolves each pin through the
pool, so a holder has either a complete pair or nothing |
| A switch mid-turn takes effect next turn | Free -- the turn holds the
binding it entered on |

Detached work inherits the context copy `asyncio` makes 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_window` and
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_window` is 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_built` existed to paper over. 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, 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. `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.

`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 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.

- `/model <id>` is refused, and the refusal names the two ways out:
`/model` for 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 model` refuses without a provider, which is the boundary
where the rule can be enforced rather than asked politely.
- `raven provider use` requires `--provider`, and names it alongside
`raven provider list` in the error.
- `--default` is not an exception: changing what new sessions start on
is the same choice about the same thing.
- `agents.defaults.provider` carries 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 #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.py` is 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_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 are unset now,
which is what "not configured" has to mean for the subsystem rule to be
expressible.

`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.
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 of `CONTEXT.md` claimed the
drop applied there too, which was wrong.

**Release note.** 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` (and `curator_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. `/model` before the first
message no longer manufactures a zero-message session record
(`session.create` is lazy, and `session.title` guards the identical case
the same way). Every rpc call in the TUI's session commands has a
`.catch` -- without it, `/model` typed before the first `session.create`
resolves 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.**

- The migration watermark was one high-water mark shared by every
stamped migration, so raising it to 2 for the provider migration re-ran
the context-window one on every config already stamped at 1 -- deleting
a `contextWindowTokens: 65536` a 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 from `CURRENT_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.
- Deleting `_adopt_provider` took its `_image_tool_result_ok.clear()`
with it. Both capability caches key on a model id but are computed from
the provider, so an `apiBase` repointed 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.
- `--default` had 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 --default` opened 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.options` re-derived the provider from the id. For a vendor with
a spec that now reads the prefix `stored_model_id` writes, but
`find_by_model` answers None for a passthrough vendor (mistral, xai), so
the picker starred `agents.defaults.provider` for 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.
- The gateway pin is documented as it behaves; see the Config defaults
section above.

**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.tsx` was rendering `<ModelPicker>` without the `scope`
prop this branch made
required, so the `scope === 'default'` footer had no coverage -- and
`tsconfig.json` excludes
`src/__tests__`, which is why `tsc --noEmit` was clean and silent about
it. And `onModelSelect`, the
callback that carries `--default` back out of the picker, had no test;
the rule it applies is now its
own function, `modelSelectCommand`, because what a selection becomes on
the command line has to
satisfy three separate constraints in `parseModelArg` at once.

**Also taken, each a defect in what this PR itself added or removed:**
`clear_session_binding` was gated on `SessionManager.delete` returning
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_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 silently, and
was stamped anyway -- never retried, on exactly the oldest configs most
likely to still say `auto`; `providers/lazy.py` and
`AgentLoop.set_provider` carried docstrings naming methods and callers
this PR deletes; onboarding still wrote `provider="auto"` when clearing
a removed provider's default; and `ui-tui/package-lock.json` is reverted
to main, its only change being npm-version drift in `libc` metadata.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
pytest tests/                     6700 passed, 33 skipped, 13 deselected   (TERM=dumb)
ruff check / format --check       clean, 832 files formatted
npx tsc --noEmit (ui-tui)         clean
npm test (ui-tui, vitest run)     998 passed (86 files)
npm run lint:rpc                  generated.ts in sync
commitlint + check_commit_messages.py   clean over github/main..HEAD
```

`tests/test_cli_theme.py::test_bold_accent_renders_styled_not_bare` is
`TERM`-dependent, not
ordering-dependent: it fails under an interactive `TERM` and passes
under `TERM=dumb`, which is what
the `coverage` target 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 `scope` prop
and the `--default` command-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-5` refused with the two ways out; `/model
openrouter/anthropic/claude-opus-4-5` refused
as well; `/model openrouter claude-opus-4-5` applied; `/model` opening
the picker at the provider
level. A real config carrying `provider: auto` and a bare
`claude-opus-4-5` migrated to `provider:
openrouter` -- the vendor it was already resolving to -- with the notice
printed and the watermark
written to the sidecar.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

User-visible behaviour changes, all deliberate:

`/model <id>`, `config.set model` without a provider, and `raven
provider use` without `--provider` now 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.provider` is 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_model` unset 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 no `provider`, and the
`-32009 model_switch_in_turn` code 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. The `config.migrations.json` sidecar 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.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: arelchan <204152633+arelchan@users.noreply.github.com>
Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants