detached c63bfd3

Update output token limit when switching models

A same-provider model switch reused the running agent and changed only
its model ID, so MaxTokens kept the previous model's output budget.
Switching from a model with a small or missing limit to a larger one,
e.g. onto Bedrock Claude Opus 5.5, kept truncating responses with
max_tokens.

Adopt the new model's MaxOutput in the interactive model swap, RPC
set_model, SDK Runtime.SetModel, and the bot's runtime reload.
Context-window checks already look up the current model on each use.
commit
c63bfd310615583691e2c8a4192de4bcd99b3e2d
author
Raymond Gasper · · 2026-09-29 13:44 UTC
parent
2ff82fa
signature
unsigned
8 files · +107 -3
M docs/rpc.md +1 -1
@@ -164,7 +164,7 @@ Drop the entire transcript. Equivalent to the `/clear` slash command.
164164
165165 ### `set_model`
166166
167-Switch model within the same provider.
167+Switch model within the same provider. Subsequent prompts use the new model's output-token limit.
168168
169169 ```json
170170 {"id":"7","type":"set_model","model":"claude-sonnet-4-5"}
@@ -164,7 +164,7 @@ Drop the entire transcript. Equivalent to the `/clear` slash command.
164 164
165 ### `set_model`165 ### `set_model`
166 166
167-Switch model within the same provider.167+Switch model within the same provider. Subsequent prompts use the new model's output-token limit.
168 168
169 ```json169 ```json
170 {"id":"7","type":"set_model","model":"claude-sonnet-4-5"}170 {"id":"7","type":"set_model","model":"claude-sonnet-4-5"}
M packages/agent/botcmd.go +1 -0
@@ -272,6 +272,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error {
272272 }
273273 agent.Client = next.NewClient()
274274 agent.Model = next.Model
275+ agent.MaxTokens = next.MaxOutput
275276 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
276277 return nil
277278 },
@@ -272,6 +272,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error {
272 }272 }
273 agent.Client = next.NewClient()273 agent.Client = next.NewClient()
274 agent.Model = next.Model274 agent.Model = next.Model
275+ agent.MaxTokens = next.MaxOutput
275 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)276 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
276 return nil277 return nil
277 },278 },
M packages/agent/modes/interactive.go +3 -0
@@ -5793,6 +5793,9 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
57935793 i.mu.Lock()
57945794 i.cfg.Model = m.ID
57955795 i.agent.Model = m.ID
5796+ // The output budget is per model; keeping the previous model's
5797+ // value can truncate responses (or exceed the new model's cap).
5798+ i.agent.MaxTokens = m.MaxOutput
57965799 i.statusOK = "model: " + m.ID
57975800 i.statusErr = ""
57985801 i.mu.Unlock()
@@ -5793,6 +5793,9 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
5793 i.mu.Lock()5793 i.mu.Lock()
5794 i.cfg.Model = m.ID5794 i.cfg.Model = m.ID
5795 i.agent.Model = m.ID5795 i.agent.Model = m.ID
5796+ // The output budget is per model; keeping the previous model's
5797+ // value can truncate responses (or exceed the new model's cap).
5798+ i.agent.MaxTokens = m.MaxOutput
5796 i.statusOK = "model: " + m.ID5799 i.statusOK = "model: " + m.ID
5797 i.statusErr = ""5800 i.statusErr = ""
5798 i.mu.Unlock()5801 i.mu.Unlock()
A packages/agent/modes/interactive_model_swap_test.go +39 -0
@@ -0,0 +1,39 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/core"
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+// A same-provider /model switch reuses the agent, so it must also adopt
11+// the new model's output budget. Keeping the previous model's MaxTokens
12+// truncated responses (e.g. a 4096 budget carried onto a 128k model).
13+func TestSameProviderModelSwapUpdatesMaxTokens(t *testing.T) {
14+ const from, to = "claude-3-5-haiku-20241022", "claude-opus-4-1-20250805"
15+ fromModel, err := provider.FindModel("anthropic", from)
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ toModel, err := provider.FindModel("anthropic", to)
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ if fromModel.MaxOutput == toModel.MaxOutput || toModel.MaxOutput <= 0 {
24+ t.Fatalf("test needs distinct positive limits, got %d and %d", fromModel.MaxOutput, toModel.MaxOutput)
25+ }
26+
27+ ag := core.NewAgent(nil, from, "", nil)
28+ ag.MaxTokens = fromModel.MaxOutput
29+ i := &Interactive{agent: ag, cfg: InteractiveConfig{Provider: "anthropic", Model: from}}
30+
31+ i.swapModel("anthropic", to, nil, false)
32+
33+ if ag.Model != to {
34+ t.Fatalf("agent model = %q, want %q", ag.Model, to)
35+ }
36+ if ag.MaxTokens != toModel.MaxOutput {
37+ t.Fatalf("agent MaxTokens = %d, want %d", ag.MaxTokens, toModel.MaxOutput)
38+ }
39+}
@@ -0,0 +1,39 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/core"
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+// A same-provider /model switch reuses the agent, so it must also adopt
11+// the new model's output budget. Keeping the previous model's MaxTokens
12+// truncated responses (e.g. a 4096 budget carried onto a 128k model).
13+func TestSameProviderModelSwapUpdatesMaxTokens(t *testing.T) {
14+ const from, to = "claude-3-5-haiku-20241022", "claude-opus-4-1-20250805"
15+ fromModel, err := provider.FindModel("anthropic", from)
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ toModel, err := provider.FindModel("anthropic", to)
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ if fromModel.MaxOutput == toModel.MaxOutput || toModel.MaxOutput <= 0 {
24+ t.Fatalf("test needs distinct positive limits, got %d and %d", fromModel.MaxOutput, toModel.MaxOutput)
25+ }
26+
27+ ag := core.NewAgent(nil, from, "", nil)
28+ ag.MaxTokens = fromModel.MaxOutput
29+ i := &Interactive{agent: ag, cfg: InteractiveConfig{Provider: "anthropic", Model: from}}
30+
31+ i.swapModel("anthropic", to, nil, false)
32+
33+ if ag.Model != to {
34+ t.Fatalf("agent model = %q, want %q", ag.Model, to)
35+ }
36+ if ag.MaxTokens != toModel.MaxOutput {
37+ t.Fatalf("agent MaxTokens = %d, want %d", ag.MaxTokens, toModel.MaxOutput)
38+ }
39+}
M packages/agent/rpc.go +3 -1
@@ -289,11 +289,13 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
289289 s.writeError(id, cmd, err.Error())
290290 return
291291 }
292- if _, err := provider.FindModel(s.provider, req.Model); err != nil {
292+ m, err := provider.FindModel(s.provider, req.Model)
293+ if err != nil {
293294 s.writeError(id, cmd, err.Error())
294295 return
295296 }
296297 s.agent.Model = req.Model
298+ s.agent.MaxTokens = m.MaxOutput
297299 s.model = req.Model
298300 s.writeResponse(id, cmd, map[string]any{"model": req.Model})
299301
@@ -289,11 +289,13 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
289 s.writeError(id, cmd, err.Error())289 s.writeError(id, cmd, err.Error())
290 return290 return
291 }291 }
292- if _, err := provider.FindModel(s.provider, req.Model); err != nil {292+ m, err := provider.FindModel(s.provider, req.Model)
293+ if err != nil {
293 s.writeError(id, cmd, err.Error())294 s.writeError(id, cmd, err.Error())
294 return295 return
295 }296 }
296 s.agent.Model = req.Model297 s.agent.Model = req.Model
298+ s.agent.MaxTokens = m.MaxOutput
297 s.model = req.Model299 s.model = req.Model
298 s.writeResponse(id, cmd, map[string]any{"model": req.Model})300 s.writeResponse(id, cmd, map[string]any{"model": req.Model})
299 301
A packages/agent/rpc_setmodel_test.go +34 -0
@@ -0,0 +1,34 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+func TestRPCSetModelUpdatesMaxTokens(t *testing.T) {
14+ const to = "claude-opus-4-1-20250805"
15+ want, err := provider.FindModel("anthropic", to)
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ ag := core.NewAgent(&startCaptureClient{}, "claude-3-5-haiku-20241022", "", nil)
20+ ag.MaxTokens = 4096
21+ var out bytes.Buffer
22+ s := &rpcServer{ctx: context.Background(), agent: ag, provider: "anthropic", model: ag.Model, out: &out}
23+
24+ raw, err := json.Marshal(map[string]string{"model": to})
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ s.dispatch("set_model", "1", raw)
29+ s.inFlight.Wait()
30+
31+ if ag.Model != to || ag.MaxTokens != want.MaxOutput {
32+ t.Fatalf("after set_model: model=%q MaxTokens=%d, want %q %d (out=%s)", ag.Model, ag.MaxTokens, to, want.MaxOutput, out.String())
33+ }
34+}
@@ -0,0 +1,34 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "testing"
8+
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/provider"
11+)
12+
13+func TestRPCSetModelUpdatesMaxTokens(t *testing.T) {
14+ const to = "claude-opus-4-1-20250805"
15+ want, err := provider.FindModel("anthropic", to)
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ ag := core.NewAgent(&startCaptureClient{}, "claude-3-5-haiku-20241022", "", nil)
20+ ag.MaxTokens = 4096
21+ var out bytes.Buffer
22+ s := &rpcServer{ctx: context.Background(), agent: ag, provider: "anthropic", model: ag.Model, out: &out}
23+
24+ raw, err := json.Marshal(map[string]string{"model": to})
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ s.dispatch("set_model", "1", raw)
29+ s.inFlight.Wait()
30+
31+ if ag.Model != to || ag.MaxTokens != want.MaxOutput {
32+ t.Fatalf("after set_model: model=%q MaxTokens=%d, want %q %d (out=%s)", ag.Model, ag.MaxTokens, to, want.MaxOutput, out.String())
33+ }
34+}
M packages/agent/sdk/sdk.go +3 -1
@@ -299,10 +299,12 @@ func (r *Runtime) SetModel(model string) error {
299299 if r.agent == nil {
300300 return fmt.Errorf("sdk: no agent")
301301 }
302- if _, err := provider.FindModel(r.provider, model); err != nil {
302+ m, err := provider.FindModel(r.provider, model)
303+ if err != nil {
303304 return err
304305 }
305306 r.agent.Model = model
307+ r.agent.MaxTokens = m.MaxOutput
306308 r.model = model
307309 return nil
308310 }
@@ -299,10 +299,12 @@ func (r *Runtime) SetModel(model string) error {
299 if r.agent == nil {299 if r.agent == nil {
300 return fmt.Errorf("sdk: no agent")300 return fmt.Errorf("sdk: no agent")
301 }301 }
302- if _, err := provider.FindModel(r.provider, model); err != nil {302+ m, err := provider.FindModel(r.provider, model)
303+ if err != nil {
303 return err304 return err
304 }305 }
305 r.agent.Model = model306 r.agent.Model = model
307+ r.agent.MaxTokens = m.MaxOutput
306 r.model = model308 r.model = model
307 return nil309 return nil
308 }310 }
A packages/agent/sdk/setmodel_test.go +23 -0
@@ -0,0 +1,23 @@
1+package sdk
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/core"
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func TestRuntimeSetModelUpdatesMaxTokens(t *testing.T) {
11+ const to = "claude-opus-4-1-20250805"
12+ want, err := provider.FindModel("anthropic", to)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ r := &Runtime{agent: &core.Agent{MaxTokens: 4096}, provider: "anthropic"}
17+ if err := r.SetModel(to); err != nil {
18+ t.Fatal(err)
19+ }
20+ if r.agent.MaxTokens != want.MaxOutput {
21+ t.Fatalf("MaxTokens = %d, want %d", r.agent.MaxTokens, want.MaxOutput)
22+ }
23+}
@@ -0,0 +1,23 @@
1+package sdk
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/core"
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func TestRuntimeSetModelUpdatesMaxTokens(t *testing.T) {
11+ const to = "claude-opus-4-1-20250805"
12+ want, err := provider.FindModel("anthropic", to)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ r := &Runtime{agent: &core.Agent{MaxTokens: 4096}, provider: "anthropic"}
17+ if err := r.SetModel(to); err != nil {
18+ t.Fatal(err)
19+ }
20+ if r.agent.MaxTokens != want.MaxOutput {
21+ t.Fatalf("MaxTokens = %d, want %d", r.agent.MaxTokens, want.MaxOutput)
22+ }
23+}

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 476

↑↓ moveenter openesc close