detached 5c1a9ec

Merge pull request #43 from bnema/fix/subagent-context-overflow

fix(subagents): recover from context limits
commit
5c1a9ec3cabbeca3f2983c636e381d137a070d80
author
bnema · · 2026-08-06 09:22 UTC
committer
GitHub · · 2026-08-06 09:22 UTC
merge of
761f038 + 1c1fef0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.1
8 files · +490 -51
M docs/subagents.md +2 -0
@@ -94,6 +94,8 @@ The worker and supervisor communicate over newline-delimited JSON. Current messa
9494
9595 A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:
9696
97+If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.
98+
9799 ```text
98100 subagent://<id>
99101 subagent://<id>/history
@@ -94,6 +94,8 @@ The worker and supervisor communicate over newline-delimited JSON. Current messa
94 94
95 A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:95 A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:
96 96
97+If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.
98+
97 ```text99 ```text
98 subagent://<id>100 subagent://<id>
99 subagent://<id>/history101 subagent://<id>/history
M packages/agent/modes/compact_queue_test.go +0 -24
@@ -430,30 +430,6 @@ func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
430430 }
431431 }
432432
433-func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
434- tests := []struct {
435- name string
436- err error
437- want bool
438- }{
439- {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
440- {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
441- {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
442- {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
443- {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
444- {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
445- {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
446- }
447-
448- for _, tt := range tests {
449- t.Run(tt.name, func(t *testing.T) {
450- if got := isContextOverflowError(tt.err); got != tt.want {
451- t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
452- }
453- })
454- }
455-}
456-
457433 func requestContainsUserText(req provider.Request, want string) bool {
458434 return requestUserTextCount(req, want) > 0
459435 }
@@ -430,30 +430,6 @@ func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
430 }430 }
431 }431 }
432 432
433-func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
434- tests := []struct {
435- name string
436- err error
437- want bool
438- }{
439- {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
440- {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
441- {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
442- {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
443- {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
444- {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
445- {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
446- }
447-
448- for _, tt := range tests {
449- t.Run(tt.name, func(t *testing.T) {
450- if got := isContextOverflowError(tt.err); got != tt.want {
451- t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
452- }
453- })
454- }
455-}
456-
457 func requestContainsUserText(req provider.Request, want string) bool {433 func requestContainsUserText(req provider.Request, want string) bool {
458 return requestUserTextCount(req, want) > 0434 return requestUserTextCount(req, want) > 0
459 }435 }
M packages/agent/modes/interactive.go +1 -22
@@ -6868,7 +6868,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
68686868 // based auto-compact can miss both when metadata is stale or the
68696869 // limit is measured in raw bytes. Compact once, then continue the
68706870 // user message already present in the transcript.
6871- contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
6871+ contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
68726872 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
68736873 if recoverContextOverflow {
68746874 i.statusErr = ""
@@ -7042,27 +7042,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
70427042 return " " + th.FGColor(th.Warning, "⚠ "+msg)
70437043 }
70447044
7045-// isContextOverflowError matches provider responses that reject the
7046-// current request because its raw payload or tokenized input is too
7047-// large. Providers surface this as either HTTP 413 or semantic context
7048-// errors with differing codes and messages.
7049-func isContextOverflowError(err error) bool {
7050- if err == nil {
7051- return false
7052- }
7053- msg := strings.ToLower(err.Error())
7054- return strings.Contains(msg, "http 413") ||
7055- strings.Contains(msg, " 413") ||
7056- strings.HasPrefix(msg, "413 ") ||
7057- strings.Contains(msg, "payload too large") ||
7058- strings.Contains(msg, "request entity too large") ||
7059- strings.Contains(msg, "input exceeds the context window") ||
7060- strings.Contains(msg, "context window exceeded") ||
7061- strings.Contains(msg, "maximum context length") ||
7062- strings.Contains(msg, "context_length_exceeded") ||
7063- (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
7064-}
7065-
70667045 const defaultAutoCompactThreshold = 85
70677046
70687047 func normalizeAutoCompactThreshold(threshold *int) int {
@@ -6868,7 +6868,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6868 // based auto-compact can miss both when metadata is stale or the6868 // based auto-compact can miss both when metadata is stale or the
6869 // limit is measured in raw bytes. Compact once, then continue the6869 // limit is measured in raw bytes. Compact once, then continue the
6870 // user message already present in the transcript.6870 // user message already present in the transcript.
6871- contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)6871+ contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
6872 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted6872 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
6873 if recoverContextOverflow {6873 if recoverContextOverflow {
6874 i.statusErr = ""6874 i.statusErr = ""
@@ -7042,27 +7042,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
7042 return " " + th.FGColor(th.Warning, "⚠ "+msg)7042 return " " + th.FGColor(th.Warning, "⚠ "+msg)
7043 }7043 }
7044 7044
7045-// isContextOverflowError matches provider responses that reject the
7046-// current request because its raw payload or tokenized input is too
7047-// large. Providers surface this as either HTTP 413 or semantic context
7048-// errors with differing codes and messages.
7049-func isContextOverflowError(err error) bool {
7050- if err == nil {
7051- return false
7052- }
7053- msg := strings.ToLower(err.Error())
7054- return strings.Contains(msg, "http 413") ||
7055- strings.Contains(msg, " 413") ||
7056- strings.HasPrefix(msg, "413 ") ||
7057- strings.Contains(msg, "payload too large") ||
7058- strings.Contains(msg, "request entity too large") ||
7059- strings.Contains(msg, "input exceeds the context window") ||
7060- strings.Contains(msg, "context window exceeded") ||
7061- strings.Contains(msg, "maximum context length") ||
7062- strings.Contains(msg, "context_length_exceeded") ||
7063- (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
7064-}
7065-
7066 const defaultAutoCompactThreshold = 857045 const defaultAutoCompactThreshold = 85
7067 7046
7068 func normalizeAutoCompactThreshold(threshold *int) int {7047 func normalizeAutoCompactThreshold(threshold *int) int {
M packages/agent/modes/rescue_dialog.go +1 -1
@@ -233,7 +233,7 @@ func classifyRescueError(err error) (bool, string) {
233233
234234 // Don't trigger on context overflow; that path already has its own
235235 // auto-compact handling.
236- if isContextOverflowError(err) {
236+ if provider.IsContextOverflowError(err) {
237237 return false, ""
238238 }
239239
@@ -233,7 +233,7 @@ func classifyRescueError(err error) (bool, string) {
233 233
234 // Don't trigger on context overflow; that path already has its own234 // Don't trigger on context overflow; that path already has its own
235 // auto-compact handling.235 // auto-compact handling.
236- if isContextOverflowError(err) {236+ if provider.IsContextOverflowError(err) {
237 return false, ""237 return false, ""
238 }238 }
239 239
M packages/agent/subagent_worker.go +67 -4
@@ -201,11 +201,14 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
201201 em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID})
202202
203203 sink := func(ev core.AgentEvent) {
204- em.emit(ev.Type(), modes.EventToJSON(ev))
204+ em.emit(ev.Type(), subagentEventData(ev))
205205 }
206206
207- start := len(ag.Messages())
208- err := ag.Prompt(c, prompt, nil, sink)
207+ var persistCompaction func([]provider.Message) error
208+ if sess != nil {
209+ persistCompaction = sess.AppendCompaction
210+ }
211+ start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction)
209212 WriteNewTranscript(ag, sess, start)
210213
211214 output := finalAssistantText(ag.Messages()[start:])
@@ -231,7 +234,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
231234 em.emit("turn_end", map[string]any{
232235 "step": step,
233236 "turn_id": turnID,
234- "error": errString(err),
237+ "error": turnErrorMessage(err),
235238 })
236239 em.emit("agent.idle", map[string]any{"turn_id": turnID})
237240
@@ -402,6 +405,14 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) {
402405 }
403406 }
404407
408+func subagentEventData(ev core.AgentEvent) map[string]any {
409+ data := modes.EventToJSON(ev)
410+ if turnEnd, ok := ev.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
411+ data["error"] = subagentContextLimitMessage
412+ }
413+ return data
414+}
415+
405416 func canonicalWorkerEvent(typ string) string {
406417 switch typ {
407418 case "agent_ready":
@@ -423,6 +434,48 @@ func canonicalWorkerEvent(typ string) string {
423434 }
424435 }
425436
437+const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"
438+
439+// promptWithContextRecovery appends prompt and runs it once. When the provider
440+// rejects that request for exceeding its context limit, it compacts the current
441+// transcript and continues the already-appended prompt exactly once.
442+//
443+// The returned index marks the first message that can contribute to the turn
444+// result or be appended after a compaction checkpoint. Compact replaces the
445+// transcript, so callers must not reuse the index captured before Prompt.
446+func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) {
447+ outputStart = len(ag.Messages())
448+ if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {
449+ return outputStart, err
450+ }
451+
452+ messages := ag.Messages()
453+ if len(messages) <= 1 {
454+ // The only message is the failed prompt. Summarizing it would replace
455+ // the user's task with an LLM-generated paraphrase, so report the
456+ // provider's limit and ask the caller to narrow the task instead.
457+ return outputStart, err
458+ }
459+ keepTail := min(4, len(messages)-1)
460+ if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {
461+ return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
462+ }
463+
464+ compacted := ag.Messages()
465+ if persistCompaction != nil {
466+ if persistErr := persistCompaction(compacted); persistErr != nil {
467+ // Keep the original history plus the user's pending task so the
468+ // outer transcript flush can persist that task without committing
469+ // an in-memory compaction that the session did not receive.
470+ ag.SetMessages(messages)
471+ return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr)
472+ }
473+ }
474+
475+ outputStart = len(compacted)
476+ return outputStart, ag.Continue(ctx, sink)
477+}
478+
426479 func finalAssistantText(messages []provider.Message) string {
427480 var out strings.Builder
428481 for _, message := range messages {
@@ -498,9 +551,19 @@ func resultErrorPayload(err error) map[string]any {
498551 if err == nil {
499552 return nil
500553 }
554+ if provider.IsContextOverflowError(err) {
555+ return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage}
556+ }
501557 return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}
502558 }
503559
560+func turnErrorMessage(err error) string {
561+ if provider.IsContextOverflowError(err) {
562+ return subagentContextLimitMessage
563+ }
564+ return errString(err)
565+}
566+
504567 func errString(err error) string {
505568 if err == nil {
506569 return ""
@@ -201,11 +201,14 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
201 em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID})201 em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID})
202 202
203 sink := func(ev core.AgentEvent) {203 sink := func(ev core.AgentEvent) {
204- em.emit(ev.Type(), modes.EventToJSON(ev))204+ em.emit(ev.Type(), subagentEventData(ev))
205 }205 }
206 206
207- start := len(ag.Messages())207+ var persistCompaction func([]provider.Message) error
208- err := ag.Prompt(c, prompt, nil, sink)208+ if sess != nil {
209+ persistCompaction = sess.AppendCompaction
210+ }
211+ start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction)
209 WriteNewTranscript(ag, sess, start)212 WriteNewTranscript(ag, sess, start)
210 213
211 output := finalAssistantText(ag.Messages()[start:])214 output := finalAssistantText(ag.Messages()[start:])
@@ -231,7 +234,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
231 em.emit("turn_end", map[string]any{234 em.emit("turn_end", map[string]any{
232 "step": step,235 "step": step,
233 "turn_id": turnID,236 "turn_id": turnID,
234- "error": errString(err),237+ "error": turnErrorMessage(err),
235 })238 })
236 em.emit("agent.idle", map[string]any{"turn_id": turnID})239 em.emit("agent.idle", map[string]any{"turn_id": turnID})
237 240
@@ -402,6 +405,14 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) {
402 }405 }
403 }406 }
404 407
408+func subagentEventData(ev core.AgentEvent) map[string]any {
409+ data := modes.EventToJSON(ev)
410+ if turnEnd, ok := ev.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
411+ data["error"] = subagentContextLimitMessage
412+ }
413+ return data
414+}
415+
405 func canonicalWorkerEvent(typ string) string {416 func canonicalWorkerEvent(typ string) string {
406 switch typ {417 switch typ {
407 case "agent_ready":418 case "agent_ready":
@@ -423,6 +434,48 @@ func canonicalWorkerEvent(typ string) string {
423 }434 }
424 }435 }
425 436
437+const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"
438+
439+// promptWithContextRecovery appends prompt and runs it once. When the provider
440+// rejects that request for exceeding its context limit, it compacts the current
441+// transcript and continues the already-appended prompt exactly once.
442+//
443+// The returned index marks the first message that can contribute to the turn
444+// result or be appended after a compaction checkpoint. Compact replaces the
445+// transcript, so callers must not reuse the index captured before Prompt.
446+func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) {
447+ outputStart = len(ag.Messages())
448+ if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {
449+ return outputStart, err
450+ }
451+
452+ messages := ag.Messages()
453+ if len(messages) <= 1 {
454+ // The only message is the failed prompt. Summarizing it would replace
455+ // the user's task with an LLM-generated paraphrase, so report the
456+ // provider's limit and ask the caller to narrow the task instead.
457+ return outputStart, err
458+ }
459+ keepTail := min(4, len(messages)-1)
460+ if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {
461+ return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
462+ }
463+
464+ compacted := ag.Messages()
465+ if persistCompaction != nil {
466+ if persistErr := persistCompaction(compacted); persistErr != nil {
467+ // Keep the original history plus the user's pending task so the
468+ // outer transcript flush can persist that task without committing
469+ // an in-memory compaction that the session did not receive.
470+ ag.SetMessages(messages)
471+ return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr)
472+ }
473+ }
474+
475+ outputStart = len(compacted)
476+ return outputStart, ag.Continue(ctx, sink)
477+}
478+
426 func finalAssistantText(messages []provider.Message) string {479 func finalAssistantText(messages []provider.Message) string {
427 var out strings.Builder480 var out strings.Builder
428 for _, message := range messages {481 for _, message := range messages {
@@ -498,9 +551,19 @@ func resultErrorPayload(err error) map[string]any {
498 if err == nil {551 if err == nil {
499 return nil552 return nil
500 }553 }
554+ if provider.IsContextOverflowError(err) {
555+ return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage}
556+ }
501 return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}557 return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}
502 }558 }
503 559
560+func turnErrorMessage(err error) string {
561+ if provider.IsContextOverflowError(err) {
562+ return subagentContextLimitMessage
563+ }
564+ return errString(err)
565+}
566+
504 func errString(err error) string {567 func errString(err error) string {
505 if err == nil {568 if err == nil {
506 return ""569 return ""
M packages/agent/subagent_worker_test.go +340 -0
@@ -2,14 +2,19 @@ package agent
22
33 import (
44 "bytes"
5+ "context"
56 "encoding/json"
7+ "errors"
68 "io"
79 "os"
810 "path/filepath"
911 "strings"
12+ "sync"
1013 "testing"
1114
1215 "github.com/bnema/zut/packages/agent/subagents"
16+ "github.com/bnema/zut/packages/core"
17+ "github.com/bnema/zut/packages/provider"
1318 )
1419
1520 // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads
@@ -30,6 +35,341 @@ func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) {
3035 }
3136 }
3237
38+type subagentContextRecoveryClient struct {
39+ mu sync.Mutex
40+ calls int
41+ requests []provider.Request
42+ retried chan provider.Request
43+ compactionErr error
44+ retryOverflow bool
45+}
46+
47+func (c *subagentContextRecoveryClient) Name() string { return "subagent-context-recovery-test" }
48+
49+func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
50+ c.mu.Lock()
51+ c.calls++
52+ call := c.calls
53+ c.requests = append(c.requests, req)
54+ c.mu.Unlock()
55+
56+ events := make(chan provider.Event, 2)
57+ go func() {
58+ defer close(events)
59+ switch call {
60+ case 1:
61+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")}
62+ case 2:
63+ if c.compactionErr != nil {
64+ events <- provider.EventDone{Stop: provider.StopError, Err: c.compactionErr}
65+ return
66+ }
67+ events <- provider.EventTextDelta{Delta: "summary"}
68+ events <- provider.EventDone{Stop: provider.StopEnd}
69+ default:
70+ c.retried <- req
71+ if c.retryOverflow {
72+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")}
73+ return
74+ }
75+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
76+ Role: provider.RoleAssistant,
77+ Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
78+ }}
79+ }
80+ }()
81+ return events, nil
82+}
83+
84+func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {
85+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
86+ agent := core.NewAgent(client, "test-model", "", nil)
87+ agent.SetMessages([]provider.Message{
88+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
89+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
90+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
91+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
92+ })
93+
94+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
95+ if err != nil {
96+ t.Fatalf("promptWithContextRecovery returned %v", err)
97+ }
98+ if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" {
99+ t.Fatalf("final assistant text = %q, want recovered", got)
100+ }
101+
102+ request := <-client.retried
103+ if got := workerRequestUserTextCount(request, "finish the task"); got != 1 {
104+ t.Fatalf("retried request contains prompt %d times, want 1: %#v", got, request.Messages)
105+ }
106+ client.mu.Lock()
107+ calls := client.calls
108+ requests := append([]provider.Request(nil), client.requests...)
109+ client.mu.Unlock()
110+ if calls != 3 || len(requests) != 3 {
111+ t.Fatalf("provider calls/requests = %d/%d, want 3/3", calls, len(requests))
112+ }
113+ if !workerRequestContainsText(requests[1], "<conversation>") || !workerRequestContainsText(requests[1], "one") {
114+ t.Fatalf("compaction request does not include the older transcript: %#v", requests[1].Messages)
115+ }
116+}
117+
118+func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) {
119+ client := &subagentContextRecoveryClient{
120+ retried: make(chan provider.Request, 1),
121+ retryOverflow: true,
122+ }
123+ agent := core.NewAgent(client, "test-model", "", nil)
124+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
125+
126+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
127+ if !provider.IsContextOverflowError(err) {
128+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
129+ }
130+ <-client.retried
131+ client.mu.Lock()
132+ calls := client.calls
133+ client.mu.Unlock()
134+ if calls != 3 {
135+ t.Fatalf("provider calls = %d, want one initial prompt, compaction, and one continuation", calls)
136+ }
137+}
138+
139+func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T) {
140+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
141+ agent := core.NewAgent(client, "test-model", "", nil)
142+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
143+ ctx, cancel := context.WithCancel(context.Background())
144+ defer cancel()
145+
146+ _, err := promptWithContextRecovery(ctx, agent, "finish the task", func(event core.AgentEvent) {
147+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
148+ cancel()
149+ }
150+ }, nil)
151+ if !provider.IsContextOverflowError(err) {
152+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
153+ }
154+ if err := ctx.Err(); !errors.Is(err, context.Canceled) {
155+ t.Fatalf("context error = %v, want %v", err, context.Canceled)
156+ }
157+ client.mu.Lock()
158+ calls := client.calls
159+ client.mu.Unlock()
160+ if calls != 1 {
161+ t.Fatalf("provider calls = %d, want only the initial prompt after cancellation", calls)
162+ }
163+}
164+
165+func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) {
166+ client := &subagentContextRecoveryClient{
167+ retried: make(chan provider.Request, 1),
168+ compactionErr: errors.New("compaction provider unavailable"),
169+ }
170+ agent := core.NewAgent(client, "test-model", "", nil)
171+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
172+
173+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
174+ if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {
175+ t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)
176+ }
177+ payload := resultErrorPayload(err)
178+ if payload["code"] != "turn_failed" {
179+ t.Fatalf("result error payload = %#v, want turn_failed", payload)
180+ }
181+}
182+
183+func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) {
184+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
185+ agent := core.NewAgent(client, "test-model", "", nil)
186+
187+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
188+ if !provider.IsContextOverflowError(err) {
189+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
190+ }
191+ client.mu.Lock()
192+ calls := client.calls
193+ requests := append([]provider.Request(nil), client.requests...)
194+ client.mu.Unlock()
195+ if len(requests) != 1 || workerRequestUserTextCount(requests[0], "finish the task") != 1 {
196+ t.Fatalf("initial request does not retain the prompt: %#v", requests)
197+ }
198+ if len(agent.Messages()) != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
199+ t.Fatalf("single prompt was not retained: %#v", agent.Messages())
200+ }
201+ if calls != 1 {
202+ t.Fatalf("provider calls = %d, want 1 without compaction", calls)
203+ }
204+}
205+
206+func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T) {
207+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
208+ history := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}
209+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
210+ if err != nil {
211+ t.Fatalf("create session: %v", err)
212+ }
213+ if err := session.AppendMessage(history[0]); err != nil {
214+ t.Fatalf("append history: %v", err)
215+ }
216+ agent := core.NewAgent(client, "test-model", "", nil)
217+ agent.SetMessages(history)
218+
219+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error {
220+ return errors.New("persist failed")
221+ })
222+ if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") {
223+ t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err)
224+ }
225+ if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
226+ t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages())
227+ }
228+ WriteNewTranscript(agent, session, outputStart)
229+ if err := session.Close(); err != nil {
230+ t.Fatalf("close session: %v", err)
231+ }
232+ reopened, messages, err := core.OpenSession(session.Path)
233+ if err != nil {
234+ t.Fatalf("reopen session: %v", err)
235+ }
236+ t.Cleanup(func() {
237+ if err := reopened.Close(); err != nil {
238+ t.Errorf("close reopened session: %v", err)
239+ }
240+ })
241+ if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
242+ t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
243+ }
244+ client.mu.Lock()
245+ calls := client.calls
246+ client.mu.Unlock()
247+ if calls != 2 {
248+ t.Fatalf("provider calls = %d, want initial prompt and compaction only", calls)
249+ }
250+}
251+
252+func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) {
253+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
254+ history := []provider.Message{
255+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
256+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
257+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
258+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
259+ }
260+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
261+ if err != nil {
262+ t.Fatalf("create session: %v", err)
263+ }
264+ for _, message := range history {
265+ if err := session.AppendMessage(message); err != nil {
266+ t.Fatalf("append history: %v", err)
267+ }
268+ }
269+
270+ agent := core.NewAgent(client, "test-model", "", nil)
271+ agent.SetMessages(history)
272+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction)
273+ if err != nil {
274+ t.Fatalf("promptWithContextRecovery returned %v", err)
275+ }
276+ WriteNewTranscript(agent, session, outputStart)
277+ if err := session.Close(); err != nil {
278+ t.Fatalf("close session: %v", err)
279+ }
280+
281+ records, err := os.ReadFile(session.Path)
282+ if err != nil {
283+ t.Fatalf("read session: %v", err)
284+ }
285+ compactions := 0
286+ for _, record := range bytes.Split(bytes.TrimSpace(records), []byte("\n")) {
287+ var entry struct {
288+ Type string `json:"type"`
289+ }
290+ if err := json.Unmarshal(record, &entry); err != nil {
291+ t.Fatalf("decode session record: %v", err)
292+ }
293+ if entry.Type == "compaction" {
294+ compactions++
295+ }
296+ }
297+ if compactions != 1 {
298+ t.Fatalf("compaction records = %d, want 1", compactions)
299+ }
300+
301+ reopened, messages, err := core.OpenSession(session.Path)
302+ if err != nil {
303+ t.Fatalf("reopen session: %v", err)
304+ }
305+ t.Cleanup(func() {
306+ if err := reopened.Close(); err != nil {
307+ t.Errorf("close reopened session: %v", err)
308+ }
309+ })
310+ if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
311+ t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
312+ }
313+ if got := finalAssistantText(messages); got != "two\nfour\nrecovered" {
314+ t.Fatalf("persisted assistant text = %q, want prior tail plus recovered result", got)
315+ }
316+}
317+
318+func workerRequestMessageTextCount(messages []provider.Message, want string) int {
319+ count := 0
320+ for _, message := range messages {
321+ for _, block := range message.Content {
322+ if text, ok := block.(provider.TextBlock); ok && text.Text == want {
323+ count++
324+ }
325+ }
326+ }
327+ return count
328+}
329+
330+func workerRequestContainsText(req provider.Request, want string) bool {
331+ for _, message := range req.Messages {
332+ for _, block := range message.Content {
333+ if text, ok := block.(provider.TextBlock); ok && strings.Contains(text.Text, want) {
334+ return true
335+ }
336+ }
337+ }
338+ return false
339+}
340+
341+func workerRequestUserTextCount(req provider.Request, want string) int {
342+ count := 0
343+ for _, message := range req.Messages {
344+ if message.Role != provider.RoleUser {
345+ continue
346+ }
347+ for _, block := range message.Content {
348+ if text, ok := block.(provider.TextBlock); ok && text.Text == want {
349+ count++
350+ }
351+ }
352+ }
353+ return count
354+}
355+
356+func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) {
357+ payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text"))
358+ if payload["code"] != "context_limit" {
359+ t.Fatalf("error code = %q, want context_limit", payload["code"])
360+ }
361+ if payload["message"] != subagentContextLimitMessage {
362+ t.Fatalf("error message = %q, want %q", payload["message"], subagentContextLimitMessage)
363+ }
364+}
365+
366+func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) {
367+ payload := subagentEventData(core.EvTurnEnd{Err: errors.New("provider error: input exceeds the context window; echoed task text")})
368+ if payload["error"] != subagentContextLimitMessage {
369+ t.Fatalf("event error = %q, want %q", payload["error"], subagentContextLimitMessage)
370+ }
371+}
372+
33373 // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the
34374 // "everything is doubled after reopening a subagent agent" bug.
35375 //
@@ -2,14 +2,19 @@ package agent
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
5 "encoding/json"6 "encoding/json"
7+ "errors"
6 "io"8 "io"
7 "os"9 "os"
8 "path/filepath"10 "path/filepath"
9 "strings"11 "strings"
12+ "sync"
10 "testing"13 "testing"
11 14
12 "github.com/bnema/zut/packages/agent/subagents"15 "github.com/bnema/zut/packages/agent/subagents"
16+ "github.com/bnema/zut/packages/core"
17+ "github.com/bnema/zut/packages/provider"
13 )18 )
14 19
15 // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads20 // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads
@@ -30,6 +35,341 @@ func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) {
30 }35 }
31 }36 }
32 37
38+type subagentContextRecoveryClient struct {
39+ mu sync.Mutex
40+ calls int
41+ requests []provider.Request
42+ retried chan provider.Request
43+ compactionErr error
44+ retryOverflow bool
45+}
46+
47+func (c *subagentContextRecoveryClient) Name() string { return "subagent-context-recovery-test" }
48+
49+func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
50+ c.mu.Lock()
51+ c.calls++
52+ call := c.calls
53+ c.requests = append(c.requests, req)
54+ c.mu.Unlock()
55+
56+ events := make(chan provider.Event, 2)
57+ go func() {
58+ defer close(events)
59+ switch call {
60+ case 1:
61+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")}
62+ case 2:
63+ if c.compactionErr != nil {
64+ events <- provider.EventDone{Stop: provider.StopError, Err: c.compactionErr}
65+ return
66+ }
67+ events <- provider.EventTextDelta{Delta: "summary"}
68+ events <- provider.EventDone{Stop: provider.StopEnd}
69+ default:
70+ c.retried <- req
71+ if c.retryOverflow {
72+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")}
73+ return
74+ }
75+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
76+ Role: provider.RoleAssistant,
77+ Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
78+ }}
79+ }
80+ }()
81+ return events, nil
82+}
83+
84+func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {
85+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
86+ agent := core.NewAgent(client, "test-model", "", nil)
87+ agent.SetMessages([]provider.Message{
88+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
89+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
90+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
91+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
92+ })
93+
94+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
95+ if err != nil {
96+ t.Fatalf("promptWithContextRecovery returned %v", err)
97+ }
98+ if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" {
99+ t.Fatalf("final assistant text = %q, want recovered", got)
100+ }
101+
102+ request := <-client.retried
103+ if got := workerRequestUserTextCount(request, "finish the task"); got != 1 {
104+ t.Fatalf("retried request contains prompt %d times, want 1: %#v", got, request.Messages)
105+ }
106+ client.mu.Lock()
107+ calls := client.calls
108+ requests := append([]provider.Request(nil), client.requests...)
109+ client.mu.Unlock()
110+ if calls != 3 || len(requests) != 3 {
111+ t.Fatalf("provider calls/requests = %d/%d, want 3/3", calls, len(requests))
112+ }
113+ if !workerRequestContainsText(requests[1], "<conversation>") || !workerRequestContainsText(requests[1], "one") {
114+ t.Fatalf("compaction request does not include the older transcript: %#v", requests[1].Messages)
115+ }
116+}
117+
118+func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) {
119+ client := &subagentContextRecoveryClient{
120+ retried: make(chan provider.Request, 1),
121+ retryOverflow: true,
122+ }
123+ agent := core.NewAgent(client, "test-model", "", nil)
124+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
125+
126+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
127+ if !provider.IsContextOverflowError(err) {
128+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
129+ }
130+ <-client.retried
131+ client.mu.Lock()
132+ calls := client.calls
133+ client.mu.Unlock()
134+ if calls != 3 {
135+ t.Fatalf("provider calls = %d, want one initial prompt, compaction, and one continuation", calls)
136+ }
137+}
138+
139+func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T) {
140+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
141+ agent := core.NewAgent(client, "test-model", "", nil)
142+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
143+ ctx, cancel := context.WithCancel(context.Background())
144+ defer cancel()
145+
146+ _, err := promptWithContextRecovery(ctx, agent, "finish the task", func(event core.AgentEvent) {
147+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
148+ cancel()
149+ }
150+ }, nil)
151+ if !provider.IsContextOverflowError(err) {
152+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
153+ }
154+ if err := ctx.Err(); !errors.Is(err, context.Canceled) {
155+ t.Fatalf("context error = %v, want %v", err, context.Canceled)
156+ }
157+ client.mu.Lock()
158+ calls := client.calls
159+ client.mu.Unlock()
160+ if calls != 1 {
161+ t.Fatalf("provider calls = %d, want only the initial prompt after cancellation", calls)
162+ }
163+}
164+
165+func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) {
166+ client := &subagentContextRecoveryClient{
167+ retried: make(chan provider.Request, 1),
168+ compactionErr: errors.New("compaction provider unavailable"),
169+ }
170+ agent := core.NewAgent(client, "test-model", "", nil)
171+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
172+
173+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
174+ if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {
175+ t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)
176+ }
177+ payload := resultErrorPayload(err)
178+ if payload["code"] != "turn_failed" {
179+ t.Fatalf("result error payload = %#v, want turn_failed", payload)
180+ }
181+}
182+
183+func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) {
184+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
185+ agent := core.NewAgent(client, "test-model", "", nil)
186+
187+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
188+ if !provider.IsContextOverflowError(err) {
189+ t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
190+ }
191+ client.mu.Lock()
192+ calls := client.calls
193+ requests := append([]provider.Request(nil), client.requests...)
194+ client.mu.Unlock()
195+ if len(requests) != 1 || workerRequestUserTextCount(requests[0], "finish the task") != 1 {
196+ t.Fatalf("initial request does not retain the prompt: %#v", requests)
197+ }
198+ if len(agent.Messages()) != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
199+ t.Fatalf("single prompt was not retained: %#v", agent.Messages())
200+ }
201+ if calls != 1 {
202+ t.Fatalf("provider calls = %d, want 1 without compaction", calls)
203+ }
204+}
205+
206+func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T) {
207+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
208+ history := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}
209+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
210+ if err != nil {
211+ t.Fatalf("create session: %v", err)
212+ }
213+ if err := session.AppendMessage(history[0]); err != nil {
214+ t.Fatalf("append history: %v", err)
215+ }
216+ agent := core.NewAgent(client, "test-model", "", nil)
217+ agent.SetMessages(history)
218+
219+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error {
220+ return errors.New("persist failed")
221+ })
222+ if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") {
223+ t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err)
224+ }
225+ if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
226+ t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages())
227+ }
228+ WriteNewTranscript(agent, session, outputStart)
229+ if err := session.Close(); err != nil {
230+ t.Fatalf("close session: %v", err)
231+ }
232+ reopened, messages, err := core.OpenSession(session.Path)
233+ if err != nil {
234+ t.Fatalf("reopen session: %v", err)
235+ }
236+ t.Cleanup(func() {
237+ if err := reopened.Close(); err != nil {
238+ t.Errorf("close reopened session: %v", err)
239+ }
240+ })
241+ if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
242+ t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
243+ }
244+ client.mu.Lock()
245+ calls := client.calls
246+ client.mu.Unlock()
247+ if calls != 2 {
248+ t.Fatalf("provider calls = %d, want initial prompt and compaction only", calls)
249+ }
250+}
251+
252+func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) {
253+ client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
254+ history := []provider.Message{
255+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
256+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
257+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
258+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
259+ }
260+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
261+ if err != nil {
262+ t.Fatalf("create session: %v", err)
263+ }
264+ for _, message := range history {
265+ if err := session.AppendMessage(message); err != nil {
266+ t.Fatalf("append history: %v", err)
267+ }
268+ }
269+
270+ agent := core.NewAgent(client, "test-model", "", nil)
271+ agent.SetMessages(history)
272+ outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction)
273+ if err != nil {
274+ t.Fatalf("promptWithContextRecovery returned %v", err)
275+ }
276+ WriteNewTranscript(agent, session, outputStart)
277+ if err := session.Close(); err != nil {
278+ t.Fatalf("close session: %v", err)
279+ }
280+
281+ records, err := os.ReadFile(session.Path)
282+ if err != nil {
283+ t.Fatalf("read session: %v", err)
284+ }
285+ compactions := 0
286+ for _, record := range bytes.Split(bytes.TrimSpace(records), []byte("\n")) {
287+ var entry struct {
288+ Type string `json:"type"`
289+ }
290+ if err := json.Unmarshal(record, &entry); err != nil {
291+ t.Fatalf("decode session record: %v", err)
292+ }
293+ if entry.Type == "compaction" {
294+ compactions++
295+ }
296+ }
297+ if compactions != 1 {
298+ t.Fatalf("compaction records = %d, want 1", compactions)
299+ }
300+
301+ reopened, messages, err := core.OpenSession(session.Path)
302+ if err != nil {
303+ t.Fatalf("reopen session: %v", err)
304+ }
305+ t.Cleanup(func() {
306+ if err := reopened.Close(); err != nil {
307+ t.Errorf("close reopened session: %v", err)
308+ }
309+ })
310+ if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 {
311+ t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages)
312+ }
313+ if got := finalAssistantText(messages); got != "two\nfour\nrecovered" {
314+ t.Fatalf("persisted assistant text = %q, want prior tail plus recovered result", got)
315+ }
316+}
317+
318+func workerRequestMessageTextCount(messages []provider.Message, want string) int {
319+ count := 0
320+ for _, message := range messages {
321+ for _, block := range message.Content {
322+ if text, ok := block.(provider.TextBlock); ok && text.Text == want {
323+ count++
324+ }
325+ }
326+ }
327+ return count
328+}
329+
330+func workerRequestContainsText(req provider.Request, want string) bool {
331+ for _, message := range req.Messages {
332+ for _, block := range message.Content {
333+ if text, ok := block.(provider.TextBlock); ok && strings.Contains(text.Text, want) {
334+ return true
335+ }
336+ }
337+ }
338+ return false
339+}
340+
341+func workerRequestUserTextCount(req provider.Request, want string) int {
342+ count := 0
343+ for _, message := range req.Messages {
344+ if message.Role != provider.RoleUser {
345+ continue
346+ }
347+ for _, block := range message.Content {
348+ if text, ok := block.(provider.TextBlock); ok && text.Text == want {
349+ count++
350+ }
351+ }
352+ }
353+ return count
354+}
355+
356+func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) {
357+ payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text"))
358+ if payload["code"] != "context_limit" {
359+ t.Fatalf("error code = %q, want context_limit", payload["code"])
360+ }
361+ if payload["message"] != subagentContextLimitMessage {
362+ t.Fatalf("error message = %q, want %q", payload["message"], subagentContextLimitMessage)
363+ }
364+}
365+
366+func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) {
367+ payload := subagentEventData(core.EvTurnEnd{Err: errors.New("provider error: input exceeds the context window; echoed task text")})
368+ if payload["error"] != subagentContextLimitMessage {
369+ t.Fatalf("event error = %q, want %q", payload["error"], subagentContextLimitMessage)
370+ }
371+}
372+
33 // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the373 // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the
34 // "everything is doubled after reopening a subagent agent" bug.374 // "everything is doubled after reopening a subagent agent" bug.
35 //375 //
A packages/provider/context_overflow.go +42 -0
@@ -0,0 +1,42 @@
1+package provider
2+
3+import "strings"
4+
5+// IsContextOverflowError reports whether err indicates that the provider
6+// rejected a request because its payload or tokenized input exceeds the
7+// serving model's context limit. Provider responses use different HTTP status
8+// codes, error codes, and prose, so callers use this semantic classification
9+// when deciding whether compaction is a safe recovery attempt.
10+func IsContextOverflowError(err error) bool {
11+ if err == nil {
12+ return false
13+ }
14+ msg := strings.ToLower(err.Error())
15+ return hasHTTPStatus(msg, "413") ||
16+ strings.Contains(msg, "payload too large") ||
17+ strings.Contains(msg, "request entity too large") ||
18+ strings.Contains(msg, "input exceeds the context window") ||
19+ strings.Contains(msg, "context window exceeded") ||
20+ strings.Contains(msg, "maximum context length") ||
21+ strings.Contains(msg, "context_length_exceeded") ||
22+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
23+}
24+
25+func hasHTTPStatus(message, code string) bool {
26+ fields := strings.Fields(message)
27+ for i, field := range fields {
28+ if strings.Trim(field, "():,;[]{}") != code {
29+ continue
30+ }
31+ if i == 0 {
32+ return true
33+ }
34+ for offset := 1; offset <= 2 && i-offset >= 0; offset++ {
35+ prefix := strings.Trim(fields[i-offset], "():,;[]{}")
36+ if prefix == "http" || prefix == "status" || prefix == "response" {
37+ return true
38+ }
39+ }
40+ }
41+ return false
42+}
@@ -0,0 +1,42 @@
1+package provider
2+
3+import "strings"
4+
5+// IsContextOverflowError reports whether err indicates that the provider
6+// rejected a request because its payload or tokenized input exceeds the
7+// serving model's context limit. Provider responses use different HTTP status
8+// codes, error codes, and prose, so callers use this semantic classification
9+// when deciding whether compaction is a safe recovery attempt.
10+func IsContextOverflowError(err error) bool {
11+ if err == nil {
12+ return false
13+ }
14+ msg := strings.ToLower(err.Error())
15+ return hasHTTPStatus(msg, "413") ||
16+ strings.Contains(msg, "payload too large") ||
17+ strings.Contains(msg, "request entity too large") ||
18+ strings.Contains(msg, "input exceeds the context window") ||
19+ strings.Contains(msg, "context window exceeded") ||
20+ strings.Contains(msg, "maximum context length") ||
21+ strings.Contains(msg, "context_length_exceeded") ||
22+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
23+}
24+
25+func hasHTTPStatus(message, code string) bool {
26+ fields := strings.Fields(message)
27+ for i, field := range fields {
28+ if strings.Trim(field, "():,;[]{}") != code {
29+ continue
30+ }
31+ if i == 0 {
32+ return true
33+ }
34+ for offset := 1; offset <= 2 && i-offset >= 0; offset++ {
35+ prefix := strings.Trim(fields[i-offset], "():,;[]{}")
36+ if prefix == "http" || prefix == "status" || prefix == "response" {
37+ return true
38+ }
39+ }
40+ }
41+ return false
42+}
A packages/provider/context_overflow_test.go +37 -0
@@ -0,0 +1,37 @@
1+package provider
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestIsContextOverflowError(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ err error
12+ want bool
13+ }{
14+ {name: "nil error", err: nil, want: false},
15+ {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
16+ {name: "response 413", err: errors.New("provider response 413: request rejected"), want: true},
17+ {name: "HTTP status code 413", err: errors.New("provider request failed: HTTP status code: 413"), want: true},
18+ {name: "payload too large", err: errors.New("proxy rejected request: Payload Too Large"), want: true},
19+ {name: "request entity too large", err: errors.New("gateway: Request Entity Too Large"), want: true},
20+ {name: "numeric value is not HTTP 413", err: errors.New("provider http 400: max_tokens cannot exceed 41344"), want: false},
21+ {name: "unqualified numeric value is not HTTP 413", err: errors.New("max_tokens cannot exceed 413"), want: false},
22+ {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
23+ {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
24+ {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
25+ {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
26+ {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
27+ {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ if got := IsContextOverflowError(tt.err); got != tt.want {
33+ t.Fatalf("IsContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
34+ }
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package provider
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestIsContextOverflowError(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ err error
12+ want bool
13+ }{
14+ {name: "nil error", err: nil, want: false},
15+ {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
16+ {name: "response 413", err: errors.New("provider response 413: request rejected"), want: true},
17+ {name: "HTTP status code 413", err: errors.New("provider request failed: HTTP status code: 413"), want: true},
18+ {name: "payload too large", err: errors.New("proxy rejected request: Payload Too Large"), want: true},
19+ {name: "request entity too large", err: errors.New("gateway: Request Entity Too Large"), want: true},
20+ {name: "numeric value is not HTTP 413", err: errors.New("provider http 400: max_tokens cannot exceed 41344"), want: false},
21+ {name: "unqualified numeric value is not HTTP 413", err: errors.New("max_tokens cannot exceed 413"), want: false},
22+ {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
23+ {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
24+ {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
25+ {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
26+ {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
27+ {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ if got := IsContextOverflowError(tt.err); got != tt.want {
33+ t.Fatalf("IsContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
34+ }
35+ })
36+ }
37+}

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