Skip to content

feat(agent): switch models mid-task - #1408

Merged
AnthonyRonning merged 6 commits into
masterfrom
agent/model-switching
Nov 1, 2026
Merged

AnthonyRonning merged 6 commits into
masterfrom
agent/model-switching

Conversation

@AnthonyRonning

@AnthonyRonning AnthonyRonning commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

A Maple Work task can now switch models between messages, on the desktop, over ACP and on remote computers, which share the runtime. Based on master a3e19782.

Why the lock existed, and why it can go

  • PR #622 locked a task to its model after the first message, because the local agent could not follow a change of context window or vision mid-task.
  • The runtime is now the Rust port of Pi, caught up to Pi 1.4.0 in PR #1389, and Pi follows a switch itself:
    • set_model records a model_change, clamps the thinking level and tells extensions;
    • read_image follows the model's vision, and text-only models get image placeholders and descriptions;
    • another model's thinking is dropped from replayed history;
    • the check before each prompt compacts against the new model's window.
  • One gap was left. Pi summarizes with the session's current model, in one request. After a switch to a smaller window, that request could carry more history than the new model's window. A Maple extension closes this gap (below).

What changed

Runtime

  • A run on another model switches the task's session, loaded or not. A task that isn't loaded opens on the model it last used and then switches, so every switch is recorded the same way.
  • The task's stored model follows from the start of the run, so reopening, the sidebar and ACP show it.
  • A switch takes the account's thinking default as it is now. Pi's switch takes the default its settings held when the session was built.
  • set_task_model accepts a selection for a started task's next run, and the store no longer refuses that patch.
  • Thinking: a started task previews, and keeps, a pick for the model it is about to switch to, without switching before its next run.

ACP

  • The selector keeps every model after the first prompt, and session/set_config_option accepts another model for a started task.
  • The first-prompt update that narrowed the selector is gone. The observer still follows a model the task switched to on another surface.
  • session/load of a task whose model the catalog no longer offers opens it on the default model, which takes over with the next prompt. Before, the load was refused.

Desktop

  • In a task, the model menu lists the same models as for a new task, with no lock icon or "Locked to this task" note.
  • A task opens on its own last model. A pick applies from the next message, not mid-run. As in PR #1360, it also becomes the default for new tasks.
  • Updates to the task on its old model keep a pending pick. A switch made by a run, here or on another surface, moves the selector.
  • The context ring and the thinking control follow the selected model.

Transcript

  • "Switched to Kimi K3" is a Maple notice entry, the mechanism behind "Stopped by user". It shows live, and it shows again from the stored entries when the task is reopened.
  • Pi's model_change entries are not projected. Telling a real switch apart from a first selection, or from a new window for the same model, needs the previous model. The incremental and chunked timeline rebuilds don't carry it, and the store has no catalog names.
  • ACP leaves notices out of its stream, as before; its selector shows the model.

Compaction after a switch to a smaller window

agent/switch_compaction.rs is a Maple extension on Pi's before_compact. The pi crates are unchanged: the diff touches nothing under crates/pi-*.

  • When it acts: only when the branch's last complete reply comes from another model whose catalog window is larger than the new model's. That model is read from the stored reply, so this holds across restarts.
  • Previous model: it writes the summary with Pi's own compact, using the parameters Pi uses (retry policy, thinking level, session id).
  • Fallback: if the catalog no longer offers the previous model, or its summary fails, the new model summarizes instead. Its input is trimmed, oldest messages first, until each summary request fits the window minus Pi's reserve and a prompt allowance. The size is estimated as Pi estimates it (characters / 4), over the text the request actually sends.
  • Otherwise it returns no summary and Pi compacts as before:
    • on the same model;
    • after a switch to a window at least as large;
    • when the new model can take the whole history;
    • when both attempts fail.
  • UPSTREAM.md now records the rule this follows. The pi crates mirror upstream, bugs included, and are changed only for a blocker. Review findings that match upstream are recorded and left. Maple behavior goes in maple-agent, usually as an extension.

Known limitations

  • An older Maple build running a task on another computer still enforces the lock. Until that computer updates, it refuses a switch with its "locked" error.
  • Over ACP, a model pick during a running turn is refused ("The task is running; its model cannot change"), as before. It applies between turns.
  • Over ACP, a pick on a started task is stored as the task's selection until its next prompt. A run of the task from another surface before then puts the task back on the model that ran.
  • Pi doesn't carry the file lists of an extension-written compaction into the next compaction's details. compact also appends those lists to the summary text, which the next summary updates.

Validation

  • just ci in apps/maple-agent passed on the final commit: format, Clippy with -D warnings for every feature set, and the workspace and headless test suites. 2,868 tests passed, 0 failed (10 ignored).
  • Pre-commit hook (just ci-lint, just ci-test) on each of the five commits; the last run passed 2,514 tests, 0 failed (8 ignored).
  • New or rewritten tests:
    • extension: no-op on the same model and on a larger window; previous model chosen from the last complete reply, failed replies skipped; trimmed fallback when the previous model fails or is gone; Pi's own summary when the history fits whole or both attempts fail; the trimmed input fits the budget;
    • scripted provider, full path: a long history on a large window, a switch to a small one, the summary by the previous model, and the run continuing on the new one; a second test where the previous model fails and the new one gets the trimmed summary;
    • runtime: switching a loaded task and a restarted task, recorded model_changes, and the notice shown live and stored; a selection for a started task; thinking for a pending model;
    • ACP: every model offered after the first prompt, and a started task switching through session/set_config_option;
    • store: a selection after the first message;
    • desktop: a task opens on its model with every model offered; a pending pick survives updates and follows switches; leaving a task restores the default.

Live run

Local maple-dev-env stack: OpenSecret, billing and the Continuum proxy. Pro fixture account, a debug build of this branch, and a scratch project in /tmp/modelswitch-qa.

For (c) and (d), a local-only patch to services/opensecret/src/model_config.rs was used. It was never committed and was reverted afterwards:

  • GLM 5.3's window went from 262,144 to 49,152;
  • for (d), DeepSeek V4.1 Flash was also set to enabled: false.

(a) DeepSeek task switched to Kimi K3. A started task's menu offers every model:

Model menu in a started task

DeepSeek read the codename from a file. After the switch, Kimi K3 answers from that earlier context:

Switched to Kimi K3 and the reply

(b) Image task switched to text-only GLM 5.3. DeepSeek saw the image natively. After the switch, GLM called read_image, which ran in describe mode (it takes context) and returned the vision helper's description. GLM's two requests show in the local Continuum proxy's log; the DeepSeek and Kimi turns don't.

GLM 5.3 answers through the read_image description

Restart. After quitting and relaunching the app, switched tasks open on their current model, with their "Switched to" line:

Kimi task after a restart

Thinking and context follow the pick. With DeepSeek picked but not yet sent in a Kimi task, the thinking menu offers DeepSeek's levels (off, low, high, xhigh, max):

Thinking menu for a pending DeepSeek pick

The context ring of a ~60k-token task fills against the picked model's window:

Context ring for DeepSeek and for a pending GLM pick

(c) Shrink path. A DeepSeek task read five ~12k-token files, then switched to GLM 5.3 (patched to 49,152). The compaction before the prompt was summarized by DeepSeek, and GLM answered from the summary:

INFO maple_agent::agent::switch_compaction] Compacted after a switch from deepseek-v4-1-flash to glm-5-3: summarized by deepseek-v4-1-flash

The proxy logged no GLM request until the reply, two seconds later.

Switch, compaction and reply on GLM

(d) Fallback. A second DeepSeek task held six ~10k-token messages, seeded over ACP. DeepSeek was then disabled on the local server, and the task switched to GLM 5.3:

INFO maple_agent::agent::switch_compaction] Compaction after a switch to glm-5-3: deepseek-v4-1-flash is no longer offered
INFO maple_agent::agent::switch_compaction] Compacted after a switch to glm-5-3: summarized by glm-5-3 from 7 of 8 messages

The oldest message was left out, GLM took the ~120 KB summary request, and it answered correctly:

Trimmed summary on GLM and the reply

Found live and fixed in the last commit: when a run that switched also compacted, the desktop showed the switch line twice until the task was reopened. The live notice now carries its stored row's identity, and a regression test checks it.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

AnthonyRonning and others added 6 commits November 1, 2026 04:43
A task was locked to its model after the first message (#622), because a
local agent could not follow a change of context window or vision. The Pi
runtime follows a switch itself now: `set_model` records it, clamps the
thinking level and tells extensions, `read_image` follows the model's
vision, and the check before each prompt compacts against the new window.

- Runtime: a run on another model switches the task's session, loaded or
  not; a cold task opens on the model it last used and then switches, so
  every switch is recorded the same way. The task's stored model follows
  from the start of the run. A switch takes the account's thinking
  default as it is now, as Pi's switch takes its settings' default.
- `set_task_model` takes a selection for any task's next run; the store no
  longer refuses a model patch after the first message.
- Thinking: a started task previews and keeps a pick for the model it
  will switch to, without switching before its next run.
- ACP: the model selector keeps every model after the first prompt, a
  started task accepts another model, and a task whose model is gone
  loads on the default instead of being refused.
- Desktop: the model menu lists every model in a task, with no lock icon
  or note. A pick applies from the next message; the selector follows a
  switch the task makes here or on another surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run that switches a started task to another model records a Maple
notice entry, "Switched to Kimi K3", and shows it live as the run starts.
It renders through the existing notice row, as "Stopped by user" does, so
reopening the task shows it from the stored entries. ACP keeps notices out
of its stream and shows the model in its selector.

Pi's own `model_change` entries are not projected: telling a switch from
the first selection or a new window for the same model needs the
previous model, which the incremental and chunked timeline rebuilds do
not carry, and the store has no catalog names.

The catalog's label precedence moves from the composer to the runtime, so
the notice and the model menu name models the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndow

Pi compacts with the session's model, in one request. Before the first
prompt on a model with a smaller window, its threshold check compacts a
history the new model may not be able to read: a 500k-token DeepSeek
task moved to a 262k model sends the summary request to the 262k model.

A Maple extension on Pi's `before_compact` takes that case. When the
branch's last complete reply comes from another model with a larger
window, that model writes the summary with Pi's own `compact`. The model
is read from the stored reply, so this holds across restarts. When the
catalog no longer offers it, or its summary fails, the new model
summarizes the history trimmed to its window, oldest messages first.

The extension returns no summary otherwise: on the same model, after a
switch to a window at least as large, when the new model takes the whole
history, and when both attempts fail. Pi then compacts as before. The pi
crates are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- UPSTREAM.md: the pi crates mirror upstream Pi, bugs included; only
  blockers are fixed locally, review findings that match upstream are
  recorded and left, and Maple behavior goes in `maple-agent`, usually as
  an extension.
- README and the scenario checklist describe switching models mid-task
  and the compaction after a switch to a smaller window.
- The catalog's reason for leaving aliases out no longer rests on the
  model lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The switch notice was published live without the identity of its stored
row. A compaction during the same run reloads the task's history and
merges the run's live rows by that identity, so the desktop showed the
notice twice until the task was opened again.

The run now links the live row to the notice's entry, as it links message
rows, and the row carries the stored identity once it commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AnthonyRonning
AnthonyRonning marked this pull request as ready for review November 1, 2026 21:35
@AnthonyRonning
AnthonyRonning merged commit 0e7dbc0 into master Nov 1, 2026
23 checks passed
@AnthonyRonning
AnthonyRonning deleted the agent/model-switching branch November 1, 2026 21:51
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.

1 participant