Merge pull request #117 from mi-skam/codex/context-overflow-recovery
Recover from context window overflows
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 | |||
| 378 | 378 | ||
| 379 | 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 | 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 | 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 | 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 | 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 | 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 | 400 | ### `/jail` | |
| 401 | 401 | ||
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 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 | 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 | 299 | for _, message := range req.Messages { | |
| 98 | 300 | if message.Role != provider.RoleUser { | |
| 99 | 301 | continue | |
| 100 | 302 | } | |
| 101 | 303 | for _, content := range message.Content { | |
| 102 | 304 | 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++ | |
| 104 | 321 | } | |
| 105 | 322 | } | |
| 106 | 323 | } | |
| 107 | - | return false | |
| 324 | + | return count | |
| 108 | 325 | } | |
| @@ -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 | continue | 301 | 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 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++ | |||
| 104 | } | 321 | } | ||
| 105 | } | 322 | } | ||
| 106 | } | 323 | } | ||
| 107 | - | return false | 324 | + | return count |
| 108 | } | 325 | } | ||
M packages/agent/modes/interactive.go +84 -28
| @@ -421,6 +421,14 @@ type Interactive struct { | |||
| 421 | 421 | // pre-turn fraction guard. Cleared by runCompact once shown. | |
| 422 | 422 | 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 | 432 | // compacting is true for both manual and automatic compaction. Prompts | |
| 425 | 433 | // submitted while it is set stay in the host queue because Compact does | |
| 426 | 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 | 5166 | // runCompact invokes core.Agent.Compact and reflects the progress in | |
| 5149 | 5167 | // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c | |
| 5150 | 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 | 5210 | i.cancelTurn = nil | |
| 5193 | 5211 | i.autoCompacting = false | |
| 5194 | 5212 | ||
| 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. | |
| 5197 | 5217 | var next string | |
| 5218 | + | var nextImages []provider.ImageBlock | |
| 5198 | 5219 | var hasNext bool | |
| 5220 | + | var continueExisting bool | |
| 5199 | 5221 | ||
| 5200 | 5222 | switch { | |
| 5201 | 5223 | case err != nil && ctx.Err() != nil: | |
| @@ -5206,6 +5228,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 5206 | 5228 | i.statusOK = "compaction cancelled" | |
| 5207 | 5229 | } | |
| 5208 | 5230 | i.queued = nil // drop queue on cancel | |
| 5231 | + | i.clearPendingCompactTurnLocked() | |
| 5209 | 5232 | if i.agent != nil { | |
| 5210 | 5233 | i.agent.DrainQueuedMessages() | |
| 5211 | 5234 | } | |
| @@ -5213,6 +5236,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 5213 | 5236 | i.statusErr = "compaction failed: " + err.Error() | |
| 5214 | 5237 | i.statusOK = "" | |
| 5215 | 5238 | i.queued = nil // drop queue on error | |
| 5239 | + | i.clearPendingCompactTurnLocked() | |
| 5216 | 5240 | if i.agent != nil { | |
| 5217 | 5241 | i.agent.DrainQueuedMessages() | |
| 5218 | 5242 | } | |
| @@ -5239,8 +5263,18 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 5239 | 5263 | i.toolOrder = nil | |
| 5240 | 5264 | i.toolGate = map[string]int{} | |
| 5241 | 5265 | 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: | |
| 5244 | 5278 | next, i.queued = i.queued[0], i.queued[1:] | |
| 5245 | 5279 | hasNext = true | |
| 5246 | 5280 | } | |
| @@ -5248,12 +5282,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 5248 | 5282 | i.mu.Unlock() | |
| 5249 | 5283 | i.invalidate() | |
| 5250 | 5284 | ||
| 5251 | - | if hasNext { | |
| 5285 | + | if hasNext || continueExisting { | |
| 5252 | 5286 | p := i.runCtx | |
| 5253 | 5287 | if p == nil { | |
| 5254 | 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 | 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 | 5409 | if i.agent == nil { | |
| 5368 | 5410 | return | |
| 5369 | 5411 | } | |
| @@ -5373,11 +5415,11 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 5373 | 5415 | // The condense flow re-fires the user's queued prompt for us, so | |
| 5374 | 5416 | // we just hand it off and exit. | |
| 5375 | 5417 | i.mu.Lock() | |
| 5376 | - | needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked() | |
| 5418 | + | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | |
| 5377 | 5419 | 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 | |
| 5381 | 5423 | i.statusErr = "" | |
| 5382 | 5424 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | |
| 5383 | 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 | 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 | 5486 | i.mu.Lock() | |
| 5440 | 5487 | i.busy = false | |
| 5441 | 5488 | // Don't touch streamPending / streamFlushPending here — the | |
| @@ -5476,15 +5523,16 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 5476 | 5523 | i.statusErr = "" | |
| 5477 | 5524 | } | |
| 5478 | 5525 | } | |
| 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 { | |
| 5486 | 5534 | i.statusErr = "" | |
| 5487 | - | i.queued = append([]string{prompt}, i.queued...) | |
| 5535 | + | i.continueAfterCompact = true | |
| 5488 | 5536 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ...")) | |
| 5489 | 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 | 5558 | // If the turn was cancelled or errored, drop the queue so the | |
| 5511 | 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 | 5561 | i.queued = nil | |
| 5514 | 5562 | if i.agent != nil { | |
| 5515 | 5563 | i.agent.DrainQueuedMessages() | |
| @@ -5535,7 +5583,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 5535 | 5583 | i.startTurn(parent, next) | |
| 5536 | 5584 | case offer: | |
| 5537 | 5585 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | |
| 5538 | - | case payloadTooLarge: | |
| 5586 | + | case recoverContextOverflow: | |
| 5539 | 5587 | i.runCompact(parent, true) | |
| 5540 | 5588 | case shouldAutoCompact: | |
| 5541 | 5589 | i.runCompact(parent, true) | |
| @@ -5614,17 +5662,25 @@ func autoCompactNoteLine(th tui.Theme, msg string) string { | |||
| 5614 | 5662 | return " " + th.FG256(th.Warning, "⚠ "+msg) | |
| 5615 | 5663 | } | |
| 5616 | 5664 | ||
| 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 { | |
| 5623 | 5670 | if err == nil { | |
| 5624 | 5671 | return false | |
| 5625 | 5672 | } | |
| 5626 | 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 | 5686 | 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 string | 422 | 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. Prompts | 432 | // compacting is true for both manual and automatic compaction. Prompts | ||
| 425 | // submitted while it is set stay in the host queue because Compact does | 433 | // 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 in | 5166 | // 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+c | 5167 | // 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 = nil | 5210 | i.cancelTurn = nil | ||
| 5193 | i.autoCompacting = false | 5211 | 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 string | 5217 | var next string | ||
| 5218 | + | var nextImages []provider.ImageBlock | |||
| 5198 | var hasNext bool | 5219 | 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 cancel | 5230 | 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 error | 5238 | 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 = nil | 5263 | 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 = true | 5279 | 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.runCtx | 5286 | 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 | return | 5410 | 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, so | 5415 | // 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 = false | 5487 | i.busy = false | ||
| 5441 | // Don't touch streamPending / streamFlushPending here — the | 5488 | // 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 provider | 5526 | + | // Detect responses that reject the current context, either as an |
| 5480 | - | // rejected the request because the request body exceeded its | 5527 | + | // HTTP 413 payload limit or a model context-window error. Token- |
| 5481 | - | // per-request limit. Token-based auto-compact can miss this | 5528 | + | // based auto-compact can miss both when metadata is stale or the |
| 5482 | - | // because the limit is on raw bytes, not tokens. Re-queue the | 5529 | + | // 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 the | 5558 | // 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 = nil | 5561 | 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 the | 5665 | + | // isContextOverflowError matches provider responses that reject the |
| 5618 | - | // provider clients. The error formatting differs slightly between | 5666 | + | // current request because its raw payload or tokenized input is too |
| 5619 | - | // providers (anthropic and openai both prepend the status code), so | 5667 | + | // large. Providers surface this as either HTTP 413 or semantic context |
| 5620 | - | // we look for the canonical 413 marker as well as the conventional | 5668 | + | // 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 false | 5671 | 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 = 85 | 5686 | const defaultAutoCompactThreshold = 85 | ||
M packages/agent/modes/rescue_dialog.go +3 -3
| @@ -231,9 +231,9 @@ func classifyRescueError(err error) (bool, string) { | |||
| 231 | 231 | msg := err.Error() | |
| 232 | 232 | low := strings.ToLower(msg) | |
| 233 | 233 | ||
| 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) { | |
| 237 | 237 | return false, "" | |
| 238 | 238 | } | |
| 239 | 239 | ||
| @@ -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 its | 234 | + | // 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 | ||||