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>
5 files · +100 -0
M packages/agent/build.go +9 -0
| @@ -32,6 +32,13 @@ type Resolved struct { | |||
| 32 | 32 | MaxSteps int | |
| 33 | 33 | 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 | 42 | // SkillTool is the on-demand skill loader registered with the | |
| 36 | 43 | // agent's tool registry, or nil if no SKILL.md files were | |
| 37 | 44 | // discovered. Exposed so the tui can list / preview skills. | |
| @@ -501,6 +508,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 501 | 508 | ToolSummary: summaries, | |
| 502 | 509 | SystemPrompt: sys, | |
| 503 | 510 | MaxSteps: max, | |
| 511 | + | MaxOutput: resolvedModel.MaxOutput, | |
| 504 | 512 | Sandbox: sandbox, | |
| 505 | 513 | SkillTool: skillTool, | |
| 506 | 514 | systemAppend: append_, | |
| @@ -768,6 +776,7 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 768 | 776 | func (r Resolved) NewAgent() *core.Agent { | |
| 769 | 777 | a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry) | |
| 770 | 778 | a.MaxSteps = r.MaxSteps | |
| 779 | + | a.MaxTokens = r.MaxOutput | |
| 771 | 780 | a.Reasoning = r.Reasoning | |
| 772 | 781 | return a | |
| 773 | 782 | } | |
| @@ -32,6 +32,13 @@ type Resolved struct { | |||||
| 32 | MaxSteps int | 32 | MaxSteps int | ||
| 33 | Sandbox *tools.Sandbox | 33 | 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 the | 42 | // SkillTool is the on-demand skill loader registered with the | ||
| 36 | // agent's tool registry, or nil if no SKILL.md files were | 43 | // 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.MaxSteps | 778 | a.MaxSteps = r.MaxSteps | ||
| 779 | + | a.MaxTokens = r.MaxOutput | |||
| 771 | a.Reasoning = r.Reasoning | 780 | a.Reasoning = r.Reasoning | ||
| 772 | return a | 781 | return a | ||
| 773 | } | 782 | } | ||
M packages/agent/modes/interactive.go +11 -0
| @@ -4656,6 +4656,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 4656 | 4656 | i.statusOK = "cancelled" | |
| 4657 | 4657 | 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 | 4670 | // Don't surface mid-loop stream errors as a red banner here. | |
| 4660 | 4671 | // EvTurnEnd fires after every step in a multi-step tool loop, | |
| 4661 | 4672 | // 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 | return | 4657 | 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 red | 4672 | // 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 { | |||
| 22 | 22 | MaxSteps int | |
| 23 | 23 | 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 | 33 | // BeforeToolExecute, if set, is called immediately before each | |
| 26 | 34 | // tool runs. Returning (allowed=false, reason) short-circuits | |
| 27 | 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 | 523 | Messages: repairToolUseResultPairs(a.Messages()), | |
| 516 | 524 | Tools: a.Tools.Specs(), | |
| 517 | 525 | Reasoning: a.Reasoning, | |
| 526 | + | MaxTokens: a.MaxTokens, | |
| 518 | 527 | } | |
| 519 | 528 | stream, err := a.Client.Stream(ctx, req) | |
| 520 | 529 | if err != nil { | |
| @@ -22,6 +22,14 @@ type Agent struct { | |||||
| 22 | MaxSteps int | 22 | MaxSteps int | ||
| 23 | Reasoning string | 23 | 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 each | 33 | // BeforeToolExecute, if set, is called immediately before each | ||
| 26 | // tool runs. Returning (allowed=false, reason) short-circuits | 34 | // 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) { | |||
| 109 | 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 | + | } | |
| @@ -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) { | |||
| 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 | 202 | func TestBedrockBuildRequestCachingClaudeModel(t *testing.T) { | |
| 167 | 203 | // A Claude model (PriceCacheWrite > 0) should get cachePoint markers | |
| 168 | 204 | // 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 markers | 203 | // 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. | ||