detached c2c9a5e

Merge #23: request model's full output-token budget per turn

Thread the resolved model's catalog MaxOutput through to
provider.Request.MaxTokens so each turn requests the model's full output
capacity. Fixes Bedrock silently truncating long writes/edits at its
4096 default (stopReason=length). Other providers already defaulted to
MaxOutput on a zero request, so this is a no-op for them. Also surfaces
StopLength explicitly in the TUI instead of ending silently.

Co-authored-by: Raymond Gasper <raymondgasper@fastmail.com>
commit
c2c9a5ea2898399e02294749f70ecfb982082c02
author
patriceckhart · · 2026-06-09 16:38 UTC
merge of
3d031dd + a373e82 · diff against the first parent
signature
unsigned
refs
tag: v0.2.23
5 files · +100 -0
M packages/agent/build.go +9 -0
@@ -32,6 +32,13 @@ type Resolved struct {
3232 MaxSteps int
3333 Sandbox *tools.Sandbox
3434
35+ // MaxOutput is the resolved model's maximum output-token budget
36+ // (from the catalog). Passed to the agent so each turn requests
37+ // the model's full output capacity instead of the provider's
38+ // conservative default (e.g. Bedrock's 4096, which truncates
39+ // long writes/edits with stopReason=length).
40+ MaxOutput int
41+
3542 // SkillTool is the on-demand skill loader registered with the
3643 // agent's tool registry, or nil if no SKILL.md files were
3744 // discovered. Exposed so the tui can list / preview skills.
@@ -501,6 +508,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
501508 ToolSummary: summaries,
502509 SystemPrompt: sys,
503510 MaxSteps: max,
511+ MaxOutput: resolvedModel.MaxOutput,
504512 Sandbox: sandbox,
505513 SkillTool: skillTool,
506514 systemAppend: append_,
@@ -768,6 +776,7 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
768776 func (r Resolved) NewAgent() *core.Agent {
769777 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
770778 a.MaxSteps = r.MaxSteps
779+ a.MaxTokens = r.MaxOutput
771780 a.Reasoning = r.Reasoning
772781 return a
773782 }
@@ -32,6 +32,13 @@ type Resolved struct {
32 MaxSteps int32 MaxSteps int
33 Sandbox *tools.Sandbox33 Sandbox *tools.Sandbox
34 34
35+ // MaxOutput is the resolved model's maximum output-token budget
36+ // (from the catalog). Passed to the agent so each turn requests
37+ // the model's full output capacity instead of the provider's
38+ // conservative default (e.g. Bedrock's 4096, which truncates
39+ // long writes/edits with stopReason=length).
40+ MaxOutput int
41+
35 // SkillTool is the on-demand skill loader registered with the42 // SkillTool is the on-demand skill loader registered with the
36 // agent's tool registry, or nil if no SKILL.md files were43 // agent's tool registry, or nil if no SKILL.md files were
37 // discovered. Exposed so the tui can list / preview skills.44 // discovered. Exposed so the tui can list / preview skills.
@@ -501,6 +508,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
501 ToolSummary: summaries,508 ToolSummary: summaries,
502 SystemPrompt: sys,509 SystemPrompt: sys,
503 MaxSteps: max,510 MaxSteps: max,
511+ MaxOutput: resolvedModel.MaxOutput,
504 Sandbox: sandbox,512 Sandbox: sandbox,
505 SkillTool: skillTool,513 SkillTool: skillTool,
506 systemAppend: append_,514 systemAppend: append_,
@@ -768,6 +776,7 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
768 func (r Resolved) NewAgent() *core.Agent {776 func (r Resolved) NewAgent() *core.Agent {
769 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)777 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
770 a.MaxSteps = r.MaxSteps778 a.MaxSteps = r.MaxSteps
779+ a.MaxTokens = r.MaxOutput
771 a.Reasoning = r.Reasoning780 a.Reasoning = r.Reasoning
772 return a781 return a
773 }782 }
M packages/agent/modes/interactive.go +11 -0
@@ -4656,6 +4656,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
46564656 i.statusOK = "cancelled"
46574657 return
46584658 }
4659+ if e.Stop == provider.StopLength {
4660+ // The model hit its output-token cap mid-response, so the
4661+ // reply (often a long write/edit) is truncated. Surface it
4662+ // explicitly, otherwise the turn just ends and reads like
4663+ // the UI gave up. The agent already requests the model's
4664+ // full MaxOutput budget, so this means the response genuinely
4665+ // exceeded that ceiling; ask the user to continue.
4666+ i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
4667+ i.statusOK = ""
4668+ return
4669+ }
46594670 // Don't surface mid-loop stream errors as a red banner here.
46604671 // EvTurnEnd fires after every step in a multi-step tool loop,
46614672 // so a transient 503 / network blip would briefly paint a red
@@ -4656,6 +4656,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
4656 i.statusOK = "cancelled"4656 i.statusOK = "cancelled"
4657 return4657 return
4658 }4658 }
4659+ if e.Stop == provider.StopLength {
4660+ // The model hit its output-token cap mid-response, so the
4661+ // reply (often a long write/edit) is truncated. Surface it
4662+ // explicitly, otherwise the turn just ends and reads like
4663+ // the UI gave up. The agent already requests the model's
4664+ // full MaxOutput budget, so this means the response genuinely
4665+ // exceeded that ceiling; ask the user to continue.
4666+ i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
4667+ i.statusOK = ""
4668+ return
4669+ }
4659 // Don't surface mid-loop stream errors as a red banner here.4670 // Don't surface mid-loop stream errors as a red banner here.
4660 // EvTurnEnd fires after every step in a multi-step tool loop,4671 // EvTurnEnd fires after every step in a multi-step tool loop,
4661 // so a transient 503 / network blip would briefly paint a red4672 // so a transient 503 / network blip would briefly paint a red
M packages/core/agent.go +9 -0
@@ -22,6 +22,14 @@ type Agent struct {
2222 MaxSteps int
2323 Reasoning string
2424
25+ // MaxTokens caps the model's output tokens per turn. Zero leaves
26+ // the field unset on the provider request, letting each provider
27+ // apply its own default (which can be conservative, e.g. Bedrock
28+ // defaults to 4096, truncating long writes/edits). Hosts populate
29+ // this from the resolved model's MaxOutput so large single-turn
30+ // responses aren't silently cut off with stopReason=length.
31+ MaxTokens int
32+
2533 // BeforeToolExecute, if set, is called immediately before each
2634 // tool runs. Returning (allowed=false, reason) short-circuits
2735 // the call with an error result containing reason. Optionally,
@@ -515,6 +523,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
515523 Messages: repairToolUseResultPairs(a.Messages()),
516524 Tools: a.Tools.Specs(),
517525 Reasoning: a.Reasoning,
526+ MaxTokens: a.MaxTokens,
518527 }
519528 stream, err := a.Client.Stream(ctx, req)
520529 if err != nil {
@@ -22,6 +22,14 @@ type Agent struct {
22 MaxSteps int22 MaxSteps int
23 Reasoning string23 Reasoning string
24 24
25+ // MaxTokens caps the model's output tokens per turn. Zero leaves
26+ // the field unset on the provider request, letting each provider
27+ // apply its own default (which can be conservative, e.g. Bedrock
28+ // defaults to 4096, truncating long writes/edits). Hosts populate
29+ // this from the resolved model's MaxOutput so large single-turn
30+ // responses aren't silently cut off with stopReason=length.
31+ MaxTokens int
32+
25 // BeforeToolExecute, if set, is called immediately before each33 // BeforeToolExecute, if set, is called immediately before each
26 // tool runs. Returning (allowed=false, reason) short-circuits34 // tool runs. Returning (allowed=false, reason) short-circuits
27 // the call with an error result containing reason. Optionally,35 // the call with an error result containing reason. Optionally,
@@ -515,6 +523,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
515 Messages: repairToolUseResultPairs(a.Messages()),523 Messages: repairToolUseResultPairs(a.Messages()),
516 Tools: a.Tools.Specs(),524 Tools: a.Tools.Specs(),
517 Reasoning: a.Reasoning,525 Reasoning: a.Reasoning,
526+ MaxTokens: a.MaxTokens,
518 }527 }
519 stream, err := a.Client.Stream(ctx, req)528 stream, err := a.Client.Stream(ctx, req)
520 if err != nil {529 if err != nil {
M packages/core/agent_retry_test.go +35 -0
@@ -109,3 +109,38 @@ func TestAgentDropsPartialAssistantBeforeRetry(t *testing.T) {
109109 t.Fatalf("final assistant text = %q; want recovered", got)
110110 }
111111 }
112+
113+// captureClient records the last Request it received so tests can
114+// assert what the agent put on the wire.
115+type captureClient struct {
116+ lastReq provider.Request
117+}
118+
119+func (c *captureClient) Name() string { return "capture" }
120+
121+func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
122+ c.lastReq = req
123+ out := make(chan provider.Event, 3)
124+ go func() {
125+ defer close(out)
126+ out <- provider.EventStart{Provider: "capture", Model: req.Model}
127+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
128+ Role: provider.RoleAssistant,
129+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
130+ }}
131+ }()
132+ return out, nil
133+}
134+
135+func TestAgentPropagatesMaxTokens(t *testing.T) {
136+ client := &captureClient{}
137+ a := NewAgent(client, "fake-model", "system", Registry{})
138+ a.MaxTokens = 64000
139+
140+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
141+ t.Fatalf("Prompt returned %v", err)
142+ }
143+ if client.lastReq.MaxTokens != 64000 {
144+ t.Fatalf("request MaxTokens = %d; want 64000 (Agent.MaxTokens not propagated)", client.lastReq.MaxTokens)
145+ }
146+}
@@ -109,3 +109,38 @@ func TestAgentDropsPartialAssistantBeforeRetry(t *testing.T) {
109 t.Fatalf("final assistant text = %q; want recovered", got)109 t.Fatalf("final assistant text = %q; want recovered", got)
110 }110 }
111 }111 }
112+
113+// captureClient records the last Request it received so tests can
114+// assert what the agent put on the wire.
115+type captureClient struct {
116+ lastReq provider.Request
117+}
118+
119+func (c *captureClient) Name() string { return "capture" }
120+
121+func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
122+ c.lastReq = req
123+ out := make(chan provider.Event, 3)
124+ go func() {
125+ defer close(out)
126+ out <- provider.EventStart{Provider: "capture", Model: req.Model}
127+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
128+ Role: provider.RoleAssistant,
129+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
130+ }}
131+ }()
132+ return out, nil
133+}
134+
135+func TestAgentPropagatesMaxTokens(t *testing.T) {
136+ client := &captureClient{}
137+ a := NewAgent(client, "fake-model", "system", Registry{})
138+ a.MaxTokens = 64000
139+
140+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
141+ t.Fatalf("Prompt returned %v", err)
142+ }
143+ if client.lastReq.MaxTokens != 64000 {
144+ t.Fatalf("request MaxTokens = %d; want 64000 (Agent.MaxTokens not propagated)", client.lastReq.MaxTokens)
145+ }
146+}
M packages/provider/amazon_bedrock_test.go +36 -0
@@ -163,6 +163,42 @@ func TestBedrockModelSupportsCaching(t *testing.T) {
163163 }
164164 }
165165
166+func TestBedrockBuildRequestMaxTokens(t *testing.T) {
167+ client := &bedrockClient{region: "us-east-1"}
168+
169+ // A non-zero MaxTokens must flow through to InferenceConfig so the
170+ // model gets its full output budget. This is the regression guard
171+ // for long writes/edits being truncated at Bedrock's 4096 default.
172+ req, err := client.buildRequest(Request{
173+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
174+ MaxTokens: 64000,
175+ Messages: []Message{
176+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
177+ },
178+ })
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ if req.InferenceConfig.MaxTokens != 64000 {
183+ t.Errorf("MaxTokens = %d, want 64000", req.InferenceConfig.MaxTokens)
184+ }
185+
186+ // Zero still falls back to the conservative provider default so an
187+ // unset budget never sends maxTokens:0 (which Bedrock rejects).
188+ reqZero, err := client.buildRequest(Request{
189+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
190+ Messages: []Message{
191+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
192+ },
193+ })
194+ if err != nil {
195+ t.Fatal(err)
196+ }
197+ if reqZero.InferenceConfig.MaxTokens != 4096 {
198+ t.Errorf("zero MaxTokens default = %d, want 4096", reqZero.InferenceConfig.MaxTokens)
199+ }
200+}
201+
166202 func TestBedrockBuildRequestCachingClaudeModel(t *testing.T) {
167203 // A Claude model (PriceCacheWrite > 0) should get cachePoint markers
168204 // in the system array and on the last user message.
@@ -163,6 +163,42 @@ func TestBedrockModelSupportsCaching(t *testing.T) {
163 }163 }
164 }164 }
165 165
166+func TestBedrockBuildRequestMaxTokens(t *testing.T) {
167+ client := &bedrockClient{region: "us-east-1"}
168+
169+ // A non-zero MaxTokens must flow through to InferenceConfig so the
170+ // model gets its full output budget. This is the regression guard
171+ // for long writes/edits being truncated at Bedrock's 4096 default.
172+ req, err := client.buildRequest(Request{
173+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
174+ MaxTokens: 64000,
175+ Messages: []Message{
176+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
177+ },
178+ })
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ if req.InferenceConfig.MaxTokens != 64000 {
183+ t.Errorf("MaxTokens = %d, want 64000", req.InferenceConfig.MaxTokens)
184+ }
185+
186+ // Zero still falls back to the conservative provider default so an
187+ // unset budget never sends maxTokens:0 (which Bedrock rejects).
188+ reqZero, err := client.buildRequest(Request{
189+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
190+ Messages: []Message{
191+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
192+ },
193+ })
194+ if err != nil {
195+ t.Fatal(err)
196+ }
197+ if reqZero.InferenceConfig.MaxTokens != 4096 {
198+ t.Errorf("zero MaxTokens default = %d, want 4096", reqZero.InferenceConfig.MaxTokens)
199+ }
200+}
201+
166 func TestBedrockBuildRequestCachingClaudeModel(t *testing.T) {202 func TestBedrockBuildRequestCachingClaudeModel(t *testing.T) {
167 // A Claude model (PriceCacheWrite > 0) should get cachePoint markers203 // A Claude model (PriceCacheWrite > 0) should get cachePoint markers
168 // in the system array and on the last user message.204 // in the system array and on the last user message.

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