detached 1c1fef0

fix(subagents): recover from context limits

commit
1c1fef041685b6719ec9d30db55c2f7fc7ec5f35
author
brice · · 2026-08-06 08:42 UTC
committer
brice · · 2026-08-06 09:18 UTC
parent
42a5079
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
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
@@ -6859,7 +6859,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
68596859 // based auto-compact can miss both when metadata is stale or the
68606860 // limit is measured in raw bytes. Compact once, then continue the
68616861 // user message already present in the transcript.
6862- contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
6862+ contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
68636863 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
68646864 if recoverContextOverflow {
68656865 i.statusErr = ""
@@ -7033,27 +7033,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
70337033 return " " + th.FGColor(th.Warning, "⚠ "+msg)
70347034 }
70357035
7036-// isContextOverflowError matches provider responses that reject the
7037-// current request because its raw payload or tokenized input is too
7038-// large. Providers surface this as either HTTP 413 or semantic context
7039-// errors with differing codes and messages.
7040-func isContextOverflowError(err error) bool {
7041- if err == nil {
7042- return false
7043- }
7044- msg := strings.ToLower(err.Error())
7045- return strings.Contains(msg, "http 413") ||
7046- strings.Contains(msg, " 413") ||
7047- strings.HasPrefix(msg, "413 ") ||
7048- strings.Contains(msg, "payload too large") ||
7049- strings.Contains(msg, "request entity too large") ||
7050- strings.Contains(msg, "input exceeds the context window") ||
7051- strings.Contains(msg, "context window exceeded") ||
7052- strings.Contains(msg, "maximum context length") ||
7053- strings.Contains(msg, "context_length_exceeded") ||
7054- (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
7055-}
7056-
70577036 const defaultAutoCompactThreshold = 85
70587037
70597038 func normalizeAutoCompactThreshold(threshold *int) int {
@@ -6859,7 +6859,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6859 // based auto-compact can miss both when metadata is stale or the6859 // based auto-compact can miss both when metadata is stale or the
6860 // limit is measured in raw bytes. Compact once, then continue the6860 // limit is measured in raw bytes. Compact once, then continue the
6861 // user message already present in the transcript.6861 // user message already present in the transcript.
6862- contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)6862+ contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
6863 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted6863 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
6864 if recoverContextOverflow {6864 if recoverContextOverflow {
6865 i.statusErr = ""6865 i.statusErr = ""
@@ -7033,27 +7033,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
7033 return " " + th.FGColor(th.Warning, "⚠ "+msg)7033 return " " + th.FGColor(th.Warning, "⚠ "+msg)
7034 }7034 }
7035 7035
7036-// isContextOverflowError matches provider responses that reject the
7037-// current request because its raw payload or tokenized input is too
7038-// large. Providers surface this as either HTTP 413 or semantic context
7039-// errors with differing codes and messages.
7040-func isContextOverflowError(err error) bool {
7041- if err == nil {
7042- return false
7043- }
7044- msg := strings.ToLower(err.Error())
7045- return strings.Contains(msg, "http 413") ||
7046- strings.Contains(msg, " 413") ||
7047- strings.HasPrefix(msg, "413 ") ||
7048- strings.Contains(msg, "payload too large") ||
7049- strings.Contains(msg, "request entity too large") ||
7050- strings.Contains(msg, "input exceeds the context window") ||
7051- strings.Contains(msg, "context window exceeded") ||
7052- strings.Contains(msg, "maximum context length") ||
7053- strings.Contains(msg, "context_length_exceeded") ||
7054- (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
7055-}
7056-
7057 const defaultAutoCompactThreshold = 857036 const defaultAutoCompactThreshold = 85
7058 7037
7059 func normalizeAutoCompactThreshold(threshold *int) int {7038 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