detached 7fe8dbc

Merge pull request #117 from mi-skam/codex/context-overflow-recovery

Recover from context window overflows
commit
7fe8dbc1c6d879e995549708624f4c1eb2eb52a4
author
Patric Eckhart · · 2026-07-29 11:13 UTC
committer
GitHub · · 2026-07-29 11:13 UTC
merge of
03bd33c + 791974f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.21
4 files · +308 -35
M README.md +2 -2
@@ -378,7 +378,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
378378
379379 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
380380 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
381-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from payload-too-large responses.
381+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
382382 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
383383 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
384384 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -395,7 +395,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
395395
396396 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
397397
398-zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a payload-too-large response.
398+zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
399399
400400 ### `/jail`
401401
@@ -378,7 +378,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
378 378
379 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.379 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
380 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.380 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
381-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from payload-too-large responses.381+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
382 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.382 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
383 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.383 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
384 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.384 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -395,7 +395,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
395 395
396 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.396 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
397 397
398-zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a payload-too-large response.398+zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
399 399
400 ### `/jail`400 ### `/jail`
401 401
M packages/agent/modes/compact_queue_test.go +219 -2
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "errors"
56 "sync"
67 "testing"
78 "time"
@@ -93,16 +94,232 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
9394 }
9495 }
9596
97+func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
98+ client := &compactQueueClient{
99+ compactionStarted: make(chan struct{}),
100+ releaseCompaction: make(chan struct{}),
101+ followUpRequest: make(chan provider.Request, 1),
102+ }
103+ agent := core.NewAgent(client, "test-model", "", nil)
104+ agent.SetMessages([]provider.Message{
105+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
106+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
107+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
108+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
109+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
110+ })
111+ threshold := 70
112+ interactive := NewInteractive(InteractiveConfig{
113+ Agent: agent,
114+ Provider: "anthropic",
115+ Model: "claude-sonnet-4-5",
116+ AutoCompactThreshold: &threshold,
117+ })
118+ interactive.runCtx = context.Background()
119+ interactive.lastCtxInput = 150000
120+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
121+
122+ interactive.startTurnWithImages(context.Background(), "follow up", []provider.ImageBlock{image})
123+ select {
124+ case <-client.compactionStarted:
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("pre-turn compaction did not start")
127+ }
128+ close(client.releaseCompaction)
129+
130+ select {
131+ case req := <-client.followUpRequest:
132+ if got := requestUserTextCount(req, "follow up"); got != 1 {
133+ t.Fatalf("follow-up request contains prompt %d times, want 1: %#v", got, req.Messages)
134+ }
135+ if got := requestImageCount(req, image); got != 1 {
136+ t.Fatalf("follow-up request contains image %d times, want 1: %#v", got, req.Messages)
137+ }
138+ case <-time.After(2 * time.Second):
139+ t.Fatal("pre-turn compaction did not start the pending image request")
140+ }
141+}
142+
143+type contextRecoveryClient struct {
144+ mu sync.Mutex
145+ calls int
146+ retried chan provider.Request
147+ overflowRetry bool
148+}
149+
150+func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }
151+
152+func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
153+ c.mu.Lock()
154+ c.calls++
155+ call := c.calls
156+ c.mu.Unlock()
157+
158+ out := make(chan provider.Event, 2)
159+ go func() {
160+ defer close(out)
161+ switch call {
162+ case 1:
163+ out <- provider.EventDone{
164+ Stop: provider.StopError,
165+ Err: errors.New("provider error: Your input exceeds the context window of this model. Please adjust your input and try again."),
166+ }
167+ case 2:
168+ out <- provider.EventTextDelta{Delta: "summary"}
169+ out <- provider.EventDone{Stop: provider.StopEnd}
170+ default:
171+ c.retried <- req
172+ if c.overflowRetry {
173+ out <- provider.EventDone{
174+ Stop: provider.StopError,
175+ Err: errors.New("context window exceeded after compaction"),
176+ }
177+ return
178+ }
179+ out <- provider.EventDone{
180+ Stop: provider.StopEnd,
181+ Message: provider.Message{
182+ Role: provider.RoleAssistant,
183+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
184+ },
185+ }
186+ }
187+ }()
188+ return out, nil
189+}
190+
191+func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) {
192+ client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}
193+ agent := core.NewAgent(client, "test-model", "", nil)
194+ agent.SetMessages([]provider.Message{
195+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
196+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
197+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
198+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
199+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
200+ })
201+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
202+ interactive.runCtx = context.Background()
203+
204+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
205+ interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image})
206+
207+ select {
208+ case req := <-client.retried:
209+ if got := requestUserTextCount(req, "retry me"); got != 1 {
210+ t.Fatalf("retried request contains original prompt %d times, want 1: %#v", got, req.Messages)
211+ }
212+ if got := requestImageCount(req, image); got != 1 {
213+ t.Fatalf("retried request contains original image %d times, want 1: %#v", got, req.Messages)
214+ }
215+ case <-time.After(2 * time.Second):
216+ t.Fatal("context-window error did not compact and retry the prompt")
217+ }
218+}
219+
220+func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
221+ client := &contextRecoveryClient{
222+ retried: make(chan provider.Request, 2),
223+ overflowRetry: true,
224+ }
225+ agent := core.NewAgent(client, "test-model", "", nil)
226+ agent.SetMessages([]provider.Message{
227+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
228+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
229+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
230+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
231+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
232+ })
233+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
234+ interactive.runCtx = context.Background()
235+
236+ interactive.startTurn(context.Background(), "still too large")
237+ select {
238+ case <-client.retried:
239+ case <-time.After(2 * time.Second):
240+ t.Fatal("context-window recovery did not retry")
241+ }
242+
243+ deadline := time.NewTimer(2 * time.Second)
244+ defer deadline.Stop()
245+ poll := time.NewTicker(time.Millisecond)
246+ defer poll.Stop()
247+ for {
248+ interactive.mu.Lock()
249+ idleWithError := !interactive.busy && interactive.statusErr != ""
250+ interactive.mu.Unlock()
251+ if idleWithError {
252+ break
253+ }
254+ select {
255+ case <-deadline.C:
256+ t.Fatal("second context-window error did not settle")
257+ case <-poll.C:
258+ }
259+ }
260+
261+ client.mu.Lock()
262+ calls := client.calls
263+ client.mu.Unlock()
264+ if calls != 3 {
265+ t.Fatalf("provider called %d times, want initial request, compaction, and one retry", calls)
266+ }
267+}
268+
269+func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
270+ tests := []struct {
271+ name string
272+ err error
273+ want bool
274+ }{
275+ {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
276+ {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
277+ {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
278+ {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
279+ {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
280+ {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
281+ {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
282+ }
283+
284+ for _, tt := range tests {
285+ t.Run(tt.name, func(t *testing.T) {
286+ if got := isContextOverflowError(tt.err); got != tt.want {
287+ t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
288+ }
289+ })
290+ }
291+}
292+
96293 func requestContainsUserText(req provider.Request, want string) bool {
294+ return requestUserTextCount(req, want) > 0
295+}
296+
297+func requestUserTextCount(req provider.Request, want string) int {
298+ count := 0
97299 for _, message := range req.Messages {
98300 if message.Role != provider.RoleUser {
99301 continue
100302 }
101303 for _, content := range message.Content {
102304 if text, ok := content.(provider.TextBlock); ok && text.Text == want {
103- return true
305+ count++
306+ }
307+ }
308+ }
309+ return count
310+}
311+
312+func requestImageCount(req provider.Request, want provider.ImageBlock) int {
313+ count := 0
314+ for _, message := range req.Messages {
315+ if message.Role != provider.RoleUser {
316+ continue
317+ }
318+ for _, content := range message.Content {
319+ if image, ok := content.(provider.ImageBlock); ok && image.MimeType == want.MimeType && string(image.Data) == string(want.Data) {
320+ count++
104321 }
105322 }
106323 }
107- return false
324+ return count
108325 }
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -93,16 +94,232 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
93 }94 }
94 }95 }
95 96
97+func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
98+ client := &compactQueueClient{
99+ compactionStarted: make(chan struct{}),
100+ releaseCompaction: make(chan struct{}),
101+ followUpRequest: make(chan provider.Request, 1),
102+ }
103+ agent := core.NewAgent(client, "test-model", "", nil)
104+ agent.SetMessages([]provider.Message{
105+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
106+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
107+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
108+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
109+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
110+ })
111+ threshold := 70
112+ interactive := NewInteractive(InteractiveConfig{
113+ Agent: agent,
114+ Provider: "anthropic",
115+ Model: "claude-sonnet-4-5",
116+ AutoCompactThreshold: &threshold,
117+ })
118+ interactive.runCtx = context.Background()
119+ interactive.lastCtxInput = 150000
120+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
121+
122+ interactive.startTurnWithImages(context.Background(), "follow up", []provider.ImageBlock{image})
123+ select {
124+ case <-client.compactionStarted:
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("pre-turn compaction did not start")
127+ }
128+ close(client.releaseCompaction)
129+
130+ select {
131+ case req := <-client.followUpRequest:
132+ if got := requestUserTextCount(req, "follow up"); got != 1 {
133+ t.Fatalf("follow-up request contains prompt %d times, want 1: %#v", got, req.Messages)
134+ }
135+ if got := requestImageCount(req, image); got != 1 {
136+ t.Fatalf("follow-up request contains image %d times, want 1: %#v", got, req.Messages)
137+ }
138+ case <-time.After(2 * time.Second):
139+ t.Fatal("pre-turn compaction did not start the pending image request")
140+ }
141+}
142+
143+type contextRecoveryClient struct {
144+ mu sync.Mutex
145+ calls int
146+ retried chan provider.Request
147+ overflowRetry bool
148+}
149+
150+func (c *contextRecoveryClient) Name() string { return "context-recovery-test" }
151+
152+func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
153+ c.mu.Lock()
154+ c.calls++
155+ call := c.calls
156+ c.mu.Unlock()
157+
158+ out := make(chan provider.Event, 2)
159+ go func() {
160+ defer close(out)
161+ switch call {
162+ case 1:
163+ out <- provider.EventDone{
164+ Stop: provider.StopError,
165+ Err: errors.New("provider error: Your input exceeds the context window of this model. Please adjust your input and try again."),
166+ }
167+ case 2:
168+ out <- provider.EventTextDelta{Delta: "summary"}
169+ out <- provider.EventDone{Stop: provider.StopEnd}
170+ default:
171+ c.retried <- req
172+ if c.overflowRetry {
173+ out <- provider.EventDone{
174+ Stop: provider.StopError,
175+ Err: errors.New("context window exceeded after compaction"),
176+ }
177+ return
178+ }
179+ out <- provider.EventDone{
180+ Stop: provider.StopEnd,
181+ Message: provider.Message{
182+ Role: provider.RoleAssistant,
183+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
184+ },
185+ }
186+ }
187+ }()
188+ return out, nil
189+}
190+
191+func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) {
192+ client := &contextRecoveryClient{retried: make(chan provider.Request, 1)}
193+ agent := core.NewAgent(client, "test-model", "", nil)
194+ agent.SetMessages([]provider.Message{
195+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
196+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
197+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
198+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
199+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
200+ })
201+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
202+ interactive.runCtx = context.Background()
203+
204+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")}
205+ interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image})
206+
207+ select {
208+ case req := <-client.retried:
209+ if got := requestUserTextCount(req, "retry me"); got != 1 {
210+ t.Fatalf("retried request contains original prompt %d times, want 1: %#v", got, req.Messages)
211+ }
212+ if got := requestImageCount(req, image); got != 1 {
213+ t.Fatalf("retried request contains original image %d times, want 1: %#v", got, req.Messages)
214+ }
215+ case <-time.After(2 * time.Second):
216+ t.Fatal("context-window error did not compact and retry the prompt")
217+ }
218+}
219+
220+func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) {
221+ client := &contextRecoveryClient{
222+ retried: make(chan provider.Request, 2),
223+ overflowRetry: true,
224+ }
225+ agent := core.NewAgent(client, "test-model", "", nil)
226+ agent.SetMessages([]provider.Message{
227+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
228+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
229+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
230+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
231+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
232+ })
233+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
234+ interactive.runCtx = context.Background()
235+
236+ interactive.startTurn(context.Background(), "still too large")
237+ select {
238+ case <-client.retried:
239+ case <-time.After(2 * time.Second):
240+ t.Fatal("context-window recovery did not retry")
241+ }
242+
243+ deadline := time.NewTimer(2 * time.Second)
244+ defer deadline.Stop()
245+ poll := time.NewTicker(time.Millisecond)
246+ defer poll.Stop()
247+ for {
248+ interactive.mu.Lock()
249+ idleWithError := !interactive.busy && interactive.statusErr != ""
250+ interactive.mu.Unlock()
251+ if idleWithError {
252+ break
253+ }
254+ select {
255+ case <-deadline.C:
256+ t.Fatal("second context-window error did not settle")
257+ case <-poll.C:
258+ }
259+ }
260+
261+ client.mu.Lock()
262+ calls := client.calls
263+ client.mu.Unlock()
264+ if calls != 3 {
265+ t.Fatalf("provider called %d times, want initial request, compaction, and one retry", calls)
266+ }
267+}
268+
269+func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) {
270+ tests := []struct {
271+ name string
272+ err error
273+ want bool
274+ }{
275+ {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true},
276+ {name: "context error code", err: errors.New("context_length_exceeded"), want: true},
277+ {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true},
278+ {name: "context window exceeded", err: errors.New("context window exceeded"), want: true},
279+ {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true},
280+ {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false},
281+ {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false},
282+ }
283+
284+ for _, tt := range tests {
285+ t.Run(tt.name, func(t *testing.T) {
286+ if got := isContextOverflowError(tt.err); got != tt.want {
287+ t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want)
288+ }
289+ })
290+ }
291+}
292+
96 func requestContainsUserText(req provider.Request, want string) bool {293 func requestContainsUserText(req provider.Request, want string) bool {
294+ return requestUserTextCount(req, want) > 0
295+}
296+
297+func requestUserTextCount(req provider.Request, want string) int {
298+ count := 0
97 for _, message := range req.Messages {299 for _, message := range req.Messages {
98 if message.Role != provider.RoleUser {300 if message.Role != provider.RoleUser {
99 continue301 continue
100 }302 }
101 for _, content := range message.Content {303 for _, content := range message.Content {
102 if text, ok := content.(provider.TextBlock); ok && text.Text == want {304 if text, ok := content.(provider.TextBlock); ok && text.Text == want {
103- return true305+ count++
306+ }
307+ }
308+ }
309+ return count
310+}
311+
312+func requestImageCount(req provider.Request, want provider.ImageBlock) int {
313+ count := 0
314+ for _, message := range req.Messages {
315+ if message.Role != provider.RoleUser {
316+ continue
317+ }
318+ for _, content := range message.Content {
319+ if image, ok := content.(provider.ImageBlock); ok && image.MimeType == want.MimeType && string(image.Data) == string(want.Data) {
320+ count++
104 }321 }
105 }322 }
106 }323 }
107- return false324+ return count
108 }325 }
M packages/agent/modes/interactive.go +84 -28
@@ -421,6 +421,14 @@ type Interactive struct {
421421 // pre-turn fraction guard. Cleared by runCompact once shown.
422422 pendingPostCompactNote string
423423
424+ // A pre-turn compaction must preserve the complete pending request,
425+ // including images. Overflow recovery instead continues the user message
426+ // already appended to the agent transcript, avoiding a duplicate message.
427+ pendingCompactPrompt string
428+ pendingCompactImages []provider.ImageBlock
429+ hasPendingCompactPrompt bool
430+ continueAfterCompact bool
431+
424432 // compacting is true for both manual and automatic compaction. Prompts
425433 // submitted while it is set stay in the host queue because Compact does
426434 // not run the agent loop that drains the agent-owned queue.
@@ -5145,6 +5153,16 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
51455153 }
51465154 }
51475155
5156+// clearPendingCompactTurnLocked drops work that only makes sense after the
5157+// current compaction. The caller must hold i.mu.
5158+func (i *Interactive) clearPendingCompactTurnLocked() {
5159+ i.pendingCompactPrompt = ""
5160+ i.pendingCompactImages = nil
5161+ i.hasPendingCompactPrompt = false
5162+ i.continueAfterCompact = false
5163+ i.pendingPostCompactNote = ""
5164+}
5165+
51485166 // runCompact invokes core.Agent.Compact and reflects the progress in
51495167 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
51505168 // cancel via the same cancelTurn channel used for normal turns.
@@ -5192,10 +5210,14 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
51925210 i.cancelTurn = nil
51935211 i.autoCompacting = false
51945212
5195- // Drain the queue: if the user typed a prompt while compacting,
5196- // fire it now that the transcript is clean.
5213+ // Drain pending work after the transcript is clean. Overflow recovery
5214+ // continues the user message already in the transcript. Pre-turn
5215+ // compaction preserves its complete text-and-images request. Regular
5216+ // prompts typed during compaction remain in the host queue.
51975217 var next string
5218+ var nextImages []provider.ImageBlock
51985219 var hasNext bool
5220+ var continueExisting bool
51995221
52005222 switch {
52015223 case err != nil && ctx.Err() != nil:
@@ -5206,6 +5228,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52065228 i.statusOK = "compaction cancelled"
52075229 }
52085230 i.queued = nil // drop queue on cancel
5231+ i.clearPendingCompactTurnLocked()
52095232 if i.agent != nil {
52105233 i.agent.DrainQueuedMessages()
52115234 }
@@ -5213,6 +5236,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52135236 i.statusErr = "compaction failed: " + err.Error()
52145237 i.statusOK = ""
52155238 i.queued = nil // drop queue on error
5239+ i.clearPendingCompactTurnLocked()
52165240 if i.agent != nil {
52175241 i.agent.DrainQueuedMessages()
52185242 }
@@ -5239,8 +5263,18 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52395263 i.toolOrder = nil
52405264 i.toolGate = map[string]int{}
52415265 i.view.InvalidateRenderCache()
5242- // Pop queued prompt if any.
5243- if len(i.queued) > 0 {
5266+ switch {
5267+ case i.continueAfterCompact:
5268+ continueExisting = true
5269+ i.continueAfterCompact = false
5270+ case i.hasPendingCompactPrompt:
5271+ next = i.pendingCompactPrompt
5272+ nextImages = i.pendingCompactImages
5273+ i.pendingCompactPrompt = ""
5274+ i.pendingCompactImages = nil
5275+ i.hasPendingCompactPrompt = false
5276+ hasNext = true
5277+ case len(i.queued) > 0:
52445278 next, i.queued = i.queued[0], i.queued[1:]
52455279 hasNext = true
52465280 }
@@ -5248,12 +5282,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
52485282 i.mu.Unlock()
52495283 i.invalidate()
52505284
5251- if hasNext {
5285+ if hasNext || continueExisting {
52525286 p := i.runCtx
52535287 if p == nil {
52545288 p = context.Background()
52555289 }
5256- i.startTurn(p, next)
5290+ if continueExisting {
5291+ i.startTurnRequest(p, "", nil, true)
5292+ } else {
5293+ i.startTurnWithImages(p, next, nextImages)
5294+ }
52575295 }
52585296 }()
52595297 }
@@ -5364,6 +5402,10 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
53645402 }
53655403
53665404 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5405+ i.startTurnRequest(parent, prompt, images, false)
5406+}
5407+
5408+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
53675409 if i.agent == nil {
53685410 return
53695411 }
@@ -5373,11 +5415,11 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
53735415 // The condense flow re-fires the user's queued prompt for us, so
53745416 // we just hand it off and exit.
53755417 i.mu.Lock()
5376- needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()
5418+ needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
53775419 if needsPreCompact {
5378- if prompt != "" {
5379- i.queued = append([]string{prompt}, i.queued...)
5380- }
5420+ i.pendingCompactPrompt = prompt
5421+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5422+ i.hasPendingCompactPrompt = true
53815423 i.statusErr = ""
53825424 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
53835425 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
@@ -5435,7 +5477,12 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
54355477 }
54365478
54375479 go func() {
5438- err := i.agent.Prompt(ctx, prompt, images, sink)
5480+ var err error
5481+ if overflowRecoveryAttempted {
5482+ err = i.agent.Continue(ctx, sink)
5483+ } else {
5484+ err = i.agent.Prompt(ctx, prompt, images, sink)
5485+ }
54395486 i.mu.Lock()
54405487 i.busy = false
54415488 // Don't touch streamPending / streamFlushPending here — the
@@ -5476,15 +5523,16 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
54765523 i.statusErr = ""
54775524 }
54785525 }
5479- // Detect HTTP 413 "payload too large" responses. The provider
5480- // rejected the request because the request body exceeded its
5481- // per-request limit. Token-based auto-compact can miss this
5482- // because the limit is on raw bytes, not tokens. Re-queue the
5483- // prompt so it survives the condense pass and trigger one.
5484- payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)
5485- if payloadTooLarge {
5526+ // Detect responses that reject the current context, either as an
5527+ // HTTP 413 payload limit or a model context-window error. Token-
5528+ // based auto-compact can miss both when metadata is stale or the
5529+ // limit is measured in raw bytes. Compact once, then continue the
5530+ // user message already present in the transcript.
5531+ contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
5532+ recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
5533+ if recoverContextOverflow {
54865534 i.statusErr = ""
5487- i.queued = append([]string{prompt}, i.queued...)
5535+ i.continueAfterCompact = true
54885536 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
54895537 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
54905538 }
@@ -5509,7 +5557,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
55095557 }
55105558 // If the turn was cancelled or errored, drop the queue so the
55115559 // user isn't bombarded with stale messages after an interrupt.
5512- if ctx.Err() != nil || err != nil {
5560+ if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
55135561 i.queued = nil
55145562 if i.agent != nil {
55155563 i.agent.DrainQueuedMessages()
@@ -5535,7 +5583,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
55355583 i.startTurn(parent, next)
55365584 case offer:
55375585 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5538- case payloadTooLarge:
5586+ case recoverContextOverflow:
55395587 i.runCompact(parent, true)
55405588 case shouldAutoCompact:
55415589 i.runCompact(parent, true)
@@ -5614,17 +5662,25 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
56145662 return " " + th.FG256(th.Warning, "⚠ "+msg)
56155663 }
56165664
5617-// isPayloadTooLargeError matches HTTP 413 responses surfaced by the
5618-// provider clients. The error formatting differs slightly between
5619-// providers (anthropic and openai both prepend the status code), so
5620-// we look for the canonical 413 marker as well as the conventional
5621-// 'payload too large' phrase.
5622-func isPayloadTooLargeError(err error) bool {
5665+// isContextOverflowError matches provider responses that reject the
5666+// current request because its raw payload or tokenized input is too
5667+// large. Providers surface this as either HTTP 413 or semantic context
5668+// errors with differing codes and messages.
5669+func isContextOverflowError(err error) bool {
56235670 if err == nil {
56245671 return false
56255672 }
56265673 msg := strings.ToLower(err.Error())
5627- return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")
5674+ return strings.Contains(msg, "http 413") ||
5675+ strings.Contains(msg, " 413") ||
5676+ strings.HasPrefix(msg, "413 ") ||
5677+ strings.Contains(msg, "payload too large") ||
5678+ strings.Contains(msg, "request entity too large") ||
5679+ strings.Contains(msg, "input exceeds the context window") ||
5680+ strings.Contains(msg, "context window exceeded") ||
5681+ strings.Contains(msg, "maximum context length") ||
5682+ strings.Contains(msg, "context_length_exceeded") ||
5683+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
56285684 }
56295685
56305686 const defaultAutoCompactThreshold = 85
@@ -421,6 +421,14 @@ type Interactive struct {
421 // pre-turn fraction guard. Cleared by runCompact once shown.421 // pre-turn fraction guard. Cleared by runCompact once shown.
422 pendingPostCompactNote string422 pendingPostCompactNote string
423 423
424+ // A pre-turn compaction must preserve the complete pending request,
425+ // including images. Overflow recovery instead continues the user message
426+ // already appended to the agent transcript, avoiding a duplicate message.
427+ pendingCompactPrompt string
428+ pendingCompactImages []provider.ImageBlock
429+ hasPendingCompactPrompt bool
430+ continueAfterCompact bool
431+
424 // compacting is true for both manual and automatic compaction. Prompts432 // compacting is true for both manual and automatic compaction. Prompts
425 // submitted while it is set stay in the host queue because Compact does433 // submitted while it is set stay in the host queue because Compact does
426 // not run the agent loop that drains the agent-owned queue.434 // not run the agent loop that drains the agent-owned queue.
@@ -5145,6 +5153,16 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
5145 }5153 }
5146 }5154 }
5147 5155
5156+// clearPendingCompactTurnLocked drops work that only makes sense after the
5157+// current compaction. The caller must hold i.mu.
5158+func (i *Interactive) clearPendingCompactTurnLocked() {
5159+ i.pendingCompactPrompt = ""
5160+ i.pendingCompactImages = nil
5161+ i.hasPendingCompactPrompt = false
5162+ i.continueAfterCompact = false
5163+ i.pendingPostCompactNote = ""
5164+}
5165+
5148 // runCompact invokes core.Agent.Compact and reflects the progress in5166 // runCompact invokes core.Agent.Compact and reflects the progress in
5149 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c5167 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5150 // cancel via the same cancelTurn channel used for normal turns.5168 // cancel via the same cancelTurn channel used for normal turns.
@@ -5192,10 +5210,14 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5192 i.cancelTurn = nil5210 i.cancelTurn = nil
5193 i.autoCompacting = false5211 i.autoCompacting = false
5194 5212
5195- // Drain the queue: if the user typed a prompt while compacting,5213+ // Drain pending work after the transcript is clean. Overflow recovery
5196- // fire it now that the transcript is clean.5214+ // continues the user message already in the transcript. Pre-turn
5215+ // compaction preserves its complete text-and-images request. Regular
5216+ // prompts typed during compaction remain in the host queue.
5197 var next string5217 var next string
5218+ var nextImages []provider.ImageBlock
5198 var hasNext bool5219 var hasNext bool
5220+ var continueExisting bool
5199 5221
5200 switch {5222 switch {
5201 case err != nil && ctx.Err() != nil:5223 case err != nil && ctx.Err() != nil:
@@ -5206,6 +5228,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5206 i.statusOK = "compaction cancelled"5228 i.statusOK = "compaction cancelled"
5207 }5229 }
5208 i.queued = nil // drop queue on cancel5230 i.queued = nil // drop queue on cancel
5231+ i.clearPendingCompactTurnLocked()
5209 if i.agent != nil {5232 if i.agent != nil {
5210 i.agent.DrainQueuedMessages()5233 i.agent.DrainQueuedMessages()
5211 }5234 }
@@ -5213,6 +5236,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5213 i.statusErr = "compaction failed: " + err.Error()5236 i.statusErr = "compaction failed: " + err.Error()
5214 i.statusOK = ""5237 i.statusOK = ""
5215 i.queued = nil // drop queue on error5238 i.queued = nil // drop queue on error
5239+ i.clearPendingCompactTurnLocked()
5216 if i.agent != nil {5240 if i.agent != nil {
5217 i.agent.DrainQueuedMessages()5241 i.agent.DrainQueuedMessages()
5218 }5242 }
@@ -5239,8 +5263,18 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5239 i.toolOrder = nil5263 i.toolOrder = nil
5240 i.toolGate = map[string]int{}5264 i.toolGate = map[string]int{}
5241 i.view.InvalidateRenderCache()5265 i.view.InvalidateRenderCache()
5242- // Pop queued prompt if any.5266+ switch {
5243- if len(i.queued) > 0 {5267+ case i.continueAfterCompact:
5268+ continueExisting = true
5269+ i.continueAfterCompact = false
5270+ case i.hasPendingCompactPrompt:
5271+ next = i.pendingCompactPrompt
5272+ nextImages = i.pendingCompactImages
5273+ i.pendingCompactPrompt = ""
5274+ i.pendingCompactImages = nil
5275+ i.hasPendingCompactPrompt = false
5276+ hasNext = true
5277+ case len(i.queued) > 0:
5244 next, i.queued = i.queued[0], i.queued[1:]5278 next, i.queued = i.queued[0], i.queued[1:]
5245 hasNext = true5279 hasNext = true
5246 }5280 }
@@ -5248,12 +5282,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5248 i.mu.Unlock()5282 i.mu.Unlock()
5249 i.invalidate()5283 i.invalidate()
5250 5284
5251- if hasNext {5285+ if hasNext || continueExisting {
5252 p := i.runCtx5286 p := i.runCtx
5253 if p == nil {5287 if p == nil {
5254 p = context.Background()5288 p = context.Background()
5255 }5289 }
5256- i.startTurn(p, next)5290+ if continueExisting {
5291+ i.startTurnRequest(p, "", nil, true)
5292+ } else {
5293+ i.startTurnWithImages(p, next, nextImages)
5294+ }
5257 }5295 }
5258 }()5296 }()
5259 }5297 }
@@ -5364,6 +5402,10 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
5364 }5402 }
5365 5403
5366 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {5404 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
5405+ i.startTurnRequest(parent, prompt, images, false)
5406+}
5407+
5408+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
5367 if i.agent == nil {5409 if i.agent == nil {
5368 return5410 return
5369 }5411 }
@@ -5373,11 +5415,11 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5373 // The condense flow re-fires the user's queued prompt for us, so5415 // The condense flow re-fires the user's queued prompt for us, so
5374 // we just hand it off and exit.5416 // we just hand it off and exit.
5375 i.mu.Lock()5417 i.mu.Lock()
5376- needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()5418+ needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
5377 if needsPreCompact {5419 if needsPreCompact {
5378- if prompt != "" {5420+ i.pendingCompactPrompt = prompt
5379- i.queued = append([]string{prompt}, i.queued...)5421+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
5380- }5422+ i.hasPendingCompactPrompt = true
5381 i.statusErr = ""5423 i.statusErr = ""
5382 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))5424 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
5383 i.pendingPostCompactNote = "context auto-compacted; sending your last message"5425 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
@@ -5435,7 +5477,12 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5435 }5477 }
5436 5478
5437 go func() {5479 go func() {
5438- err := i.agent.Prompt(ctx, prompt, images, sink)5480+ var err error
5481+ if overflowRecoveryAttempted {
5482+ err = i.agent.Continue(ctx, sink)
5483+ } else {
5484+ err = i.agent.Prompt(ctx, prompt, images, sink)
5485+ }
5439 i.mu.Lock()5486 i.mu.Lock()
5440 i.busy = false5487 i.busy = false
5441 // Don't touch streamPending / streamFlushPending here — the5488 // Don't touch streamPending / streamFlushPending here — the
@@ -5476,15 +5523,16 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5476 i.statusErr = ""5523 i.statusErr = ""
5477 }5524 }
5478 }5525 }
5479- // Detect HTTP 413 "payload too large" responses. The provider5526+ // Detect responses that reject the current context, either as an
5480- // rejected the request because the request body exceeded its5527+ // HTTP 413 payload limit or a model context-window error. Token-
5481- // per-request limit. Token-based auto-compact can miss this5528+ // based auto-compact can miss both when metadata is stale or the
5482- // because the limit is on raw bytes, not tokens. Re-queue the5529+ // limit is measured in raw bytes. Compact once, then continue the
5483- // prompt so it survives the condense pass and trigger one.5530+ // user message already present in the transcript.
5484- payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)5531+ contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
5485- if payloadTooLarge {5532+ recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
5533+ if recoverContextOverflow {
5486 i.statusErr = ""5534 i.statusErr = ""
5487- i.queued = append([]string{prompt}, i.queued...)5535+ i.continueAfterCompact = true
5488 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))5536 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
5489 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"5537 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
5490 }5538 }
@@ -5509,7 +5557,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5509 }5557 }
5510 // If the turn was cancelled or errored, drop the queue so the5558 // If the turn was cancelled or errored, drop the queue so the
5511 // user isn't bombarded with stale messages after an interrupt.5559 // user isn't bombarded with stale messages after an interrupt.
5512- if ctx.Err() != nil || err != nil {5560+ if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
5513 i.queued = nil5561 i.queued = nil
5514 if i.agent != nil {5562 if i.agent != nil {
5515 i.agent.DrainQueuedMessages()5563 i.agent.DrainQueuedMessages()
@@ -5535,7 +5583,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5535 i.startTurn(parent, next)5583 i.startTurn(parent, next)
5536 case offer:5584 case offer:
5537 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)5585 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
5538- case payloadTooLarge:5586+ case recoverContextOverflow:
5539 i.runCompact(parent, true)5587 i.runCompact(parent, true)
5540 case shouldAutoCompact:5588 case shouldAutoCompact:
5541 i.runCompact(parent, true)5589 i.runCompact(parent, true)
@@ -5614,17 +5662,25 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
5614 return " " + th.FG256(th.Warning, "⚠ "+msg)5662 return " " + th.FG256(th.Warning, "⚠ "+msg)
5615 }5663 }
5616 5664
5617-// isPayloadTooLargeError matches HTTP 413 responses surfaced by the5665+// isContextOverflowError matches provider responses that reject the
5618-// provider clients. The error formatting differs slightly between5666+// current request because its raw payload or tokenized input is too
5619-// providers (anthropic and openai both prepend the status code), so5667+// large. Providers surface this as either HTTP 413 or semantic context
5620-// we look for the canonical 413 marker as well as the conventional5668+// errors with differing codes and messages.
5621-// 'payload too large' phrase.5669+func isContextOverflowError(err error) bool {
5622-func isPayloadTooLargeError(err error) bool {
5623 if err == nil {5670 if err == nil {
5624 return false5671 return false
5625 }5672 }
5626 msg := strings.ToLower(err.Error())5673 msg := strings.ToLower(err.Error())
5627- return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")5674+ return strings.Contains(msg, "http 413") ||
5675+ strings.Contains(msg, " 413") ||
5676+ strings.HasPrefix(msg, "413 ") ||
5677+ strings.Contains(msg, "payload too large") ||
5678+ strings.Contains(msg, "request entity too large") ||
5679+ strings.Contains(msg, "input exceeds the context window") ||
5680+ strings.Contains(msg, "context window exceeded") ||
5681+ strings.Contains(msg, "maximum context length") ||
5682+ strings.Contains(msg, "context_length_exceeded") ||
5683+ (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
5628 }5684 }
5629 5685
5630 const defaultAutoCompactThreshold = 855686 const defaultAutoCompactThreshold = 85
M packages/agent/modes/rescue_dialog.go +3 -3
@@ -231,9 +231,9 @@ func classifyRescueError(err error) (bool, string) {
231231 msg := err.Error()
232232 low := strings.ToLower(msg)
233233
234- // Don't trigger on payload-too-large; that path already has its
235- // own auto-compact handling.
236- if isPayloadTooLargeError(err) {
234+ // Don't trigger on context overflow; that path already has its own
235+ // auto-compact handling.
236+ if isContextOverflowError(err) {
237237 return false, ""
238238 }
239239
@@ -231,9 +231,9 @@ func classifyRescueError(err error) (bool, string) {
231 msg := err.Error()231 msg := err.Error()
232 low := strings.ToLower(msg)232 low := strings.ToLower(msg)
233 233
234- // Don't trigger on payload-too-large; that path already has its234+ // Don't trigger on context overflow; that path already has its own
235- // own auto-compact handling.235+ // auto-compact handling.
236- if isPayloadTooLargeError(err) {236+ if isContextOverflowError(err) {
237 return false, ""237 return false, ""
238 }238 }
239 239

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