detached 7cf3d6c

Reject SDK model switches during active turns

commit
7cf3d6c02db68fc87442ad6c1cbdf710a940f996
author
patriceckhart · · 2026-09-29 20:13 UTC
parent
b0a07d3
signature
unsigned
2 files · +32 -3
M packages/agent/sdk/sdk.go +7 -3
@@ -292,13 +292,17 @@ func (r *Runtime) Compact(ctx context.Context, customInstructions string) (Compa
292292 }
293293
294294 // SetModel switches the active model. Same provider only; for cross-
295-// provider switches, create a new Runtime.
295+// provider switches, create a new Runtime. Returns ErrBusy during a
296+// Prompt or Compact so the model and output limit cannot change mid-turn.
296297 func (r *Runtime) SetModel(model string) error {
297298 r.mu.Lock()
298299 defer r.mu.Unlock()
299300 if r.agent == nil {
300301 return fmt.Errorf("sdk: no agent")
301302 }
303+ if r.activeCancel != nil {
304+ return ErrBusy
305+ }
302306 m, err := provider.FindModel(r.provider, model)
303307 if err != nil {
304308 return err
@@ -377,8 +381,8 @@ func (r *Runtime) ListModels() []ModelInfo {
377381
378382 // ---- public errors ----
379383
380-// ErrBusy is returned when a Prompt or Compact is started while
381-// another is in flight on the same Runtime.
384+// ErrBusy is returned when a Prompt, Compact, or SetModel is called
385+// during an active Prompt or Compact on the same Runtime.
382386 var ErrBusy = errors.New("sdk: runtime is busy")
383387
384388 // ErrClosed is returned by methods on a Runtime after Close.
@@ -292,13 +292,17 @@ func (r *Runtime) Compact(ctx context.Context, customInstructions string) (Compa
292 }292 }
293 293
294 // SetModel switches the active model. Same provider only; for cross-294 // SetModel switches the active model. Same provider only; for cross-
295-// provider switches, create a new Runtime.295+// provider switches, create a new Runtime. Returns ErrBusy during a
296+// Prompt or Compact so the model and output limit cannot change mid-turn.
296 func (r *Runtime) SetModel(model string) error {297 func (r *Runtime) SetModel(model string) error {
297 r.mu.Lock()298 r.mu.Lock()
298 defer r.mu.Unlock()299 defer r.mu.Unlock()
299 if r.agent == nil {300 if r.agent == nil {
300 return fmt.Errorf("sdk: no agent")301 return fmt.Errorf("sdk: no agent")
301 }302 }
303+ if r.activeCancel != nil {
304+ return ErrBusy
305+ }
302 m, err := provider.FindModel(r.provider, model)306 m, err := provider.FindModel(r.provider, model)
303 if err != nil {307 if err != nil {
304 return err308 return err
@@ -377,8 +381,8 @@ func (r *Runtime) ListModels() []ModelInfo {
377 381
378 // ---- public errors ----382 // ---- public errors ----
379 383
380-// ErrBusy is returned when a Prompt or Compact is started while384+// ErrBusy is returned when a Prompt, Compact, or SetModel is called
381-// another is in flight on the same Runtime.385+// during an active Prompt or Compact on the same Runtime.
382 var ErrBusy = errors.New("sdk: runtime is busy")386 var ErrBusy = errors.New("sdk: runtime is busy")
383 387
384 // ErrClosed is returned by methods on a Runtime after Close.388 // ErrClosed is returned by methods on a Runtime after Close.
M packages/agent/sdk/setmodel_test.go +25 -0
@@ -1,12 +1,37 @@
11 package sdk
22
33 import (
4+ "errors"
45 "testing"
56
67 "github.com/patriceckhart/zot/packages/core"
78 "github.com/patriceckhart/zot/packages/provider"
89 )
910
11+func TestRuntimeSetModelRejectsBusyAgent(t *testing.T) {
12+ const from, to = "claude-3-5-haiku-20241022", "claude-opus-4-1-20250805"
13+ r := &Runtime{
14+ agent: &core.Agent{Model: from, MaxTokens: 4096},
15+ provider: "anthropic",
16+ model: from,
17+ activeCancel: func() {}, // Prompt and Compact both hold this until complete.
18+ }
19+ if err := r.SetModel(to); !errors.Is(err, ErrBusy) {
20+ t.Fatalf("SetModel during active turn = %v, want ErrBusy", err)
21+ }
22+ if r.model != from || r.agent.Model != from || r.agent.MaxTokens != 4096 {
23+ t.Fatalf("busy SetModel changed model or limit: runtime=%q agent=%q max=%d", r.model, r.agent.Model, r.agent.MaxTokens)
24+ }
25+
26+ r.activeCancel = nil
27+ if err := r.SetModel(to); err != nil {
28+ t.Fatalf("SetModel after turn: %v", err)
29+ }
30+ if r.model != to || r.agent.Model != to {
31+ t.Fatalf("SetModel after turn: runtime=%q agent=%q, want %q", r.model, r.agent.Model, to)
32+ }
33+}
34+
1035 func TestRuntimeSetModelUpdatesMaxTokens(t *testing.T) {
1136 const to = "claude-opus-4-1-20250805"
1237 want, err := provider.FindModel("anthropic", to)
@@ -1,12 +1,37 @@
1 package sdk1 package sdk
2 2
3 import (3 import (
4+ "errors"
4 "testing"5 "testing"
5 6
6 "github.com/patriceckhart/zot/packages/core"7 "github.com/patriceckhart/zot/packages/core"
7 "github.com/patriceckhart/zot/packages/provider"8 "github.com/patriceckhart/zot/packages/provider"
8 )9 )
9 10
11+func TestRuntimeSetModelRejectsBusyAgent(t *testing.T) {
12+ const from, to = "claude-3-5-haiku-20241022", "claude-opus-4-1-20250805"
13+ r := &Runtime{
14+ agent: &core.Agent{Model: from, MaxTokens: 4096},
15+ provider: "anthropic",
16+ model: from,
17+ activeCancel: func() {}, // Prompt and Compact both hold this until complete.
18+ }
19+ if err := r.SetModel(to); !errors.Is(err, ErrBusy) {
20+ t.Fatalf("SetModel during active turn = %v, want ErrBusy", err)
21+ }
22+ if r.model != from || r.agent.Model != from || r.agent.MaxTokens != 4096 {
23+ t.Fatalf("busy SetModel changed model or limit: runtime=%q agent=%q max=%d", r.model, r.agent.Model, r.agent.MaxTokens)
24+ }
25+
26+ r.activeCancel = nil
27+ if err := r.SetModel(to); err != nil {
28+ t.Fatalf("SetModel after turn: %v", err)
29+ }
30+ if r.model != to || r.agent.Model != to {
31+ t.Fatalf("SetModel after turn: runtime=%q agent=%q, want %q", r.model, r.agent.Model, to)
32+ }
33+}
34+
10 func TestRuntimeSetModelUpdatesMaxTokens(t *testing.T) {35 func TestRuntimeSetModelUpdatesMaxTokens(t *testing.T) {
11 const to = "claude-opus-4-1-20250805"36 const to = "claude-opus-4-1-20250805"
12 want, err := provider.FindModel("anthropic", to)37 want, err := provider.FindModel("anthropic", to)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close