Recover from context window overflows
- commit
- de925da5a704912222a7823a8d3205d96237b940
- author
- mi-skam · · 2026-07-29 09:42 UTC
- parent
- d3084cc
- signature
- SSH Unverified
SHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0wThe key is unknown
4 files · +118 -21
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 +89 -0
| @@ -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,6 +94,94 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | |||
| 93 | 94 | } | |
| 94 | 95 | } | |
| 95 | 96 | ||
| 97 | + | type contextRecoveryClient struct { | |
| 98 | + | mu sync.Mutex | |
| 99 | + | calls int | |
| 100 | + | retried chan provider.Request | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (c *contextRecoveryClient) Name() string { return "context-recovery-test" } | |
| 104 | + | ||
| 105 | + | func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 106 | + | c.mu.Lock() | |
| 107 | + | c.calls++ | |
| 108 | + | call := c.calls | |
| 109 | + | c.mu.Unlock() | |
| 110 | + | ||
| 111 | + | out := make(chan provider.Event, 2) | |
| 112 | + | go func() { | |
| 113 | + | defer close(out) | |
| 114 | + | switch call { | |
| 115 | + | case 1: | |
| 116 | + | out <- provider.EventDone{ | |
| 117 | + | Stop: provider.StopError, | |
| 118 | + | Err: errors.New("provider error: Your input exceeds the context window of this model. Please adjust your input and try again."), | |
| 119 | + | } | |
| 120 | + | case 2: | |
| 121 | + | out <- provider.EventTextDelta{Delta: "summary"} | |
| 122 | + | out <- provider.EventDone{Stop: provider.StopEnd} | |
| 123 | + | default: | |
| 124 | + | c.retried <- req | |
| 125 | + | out <- provider.EventDone{ | |
| 126 | + | Stop: provider.StopEnd, | |
| 127 | + | Message: provider.Message{ | |
| 128 | + | Role: provider.RoleAssistant, | |
| 129 | + | Content: []provider.Content{provider.TextBlock{Text: "done"}}, | |
| 130 | + | }, | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | }() | |
| 134 | + | return out, nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) { | |
| 138 | + | client := &contextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 139 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 140 | + | agent.SetMessages([]provider.Message{ | |
| 141 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |
| 142 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |
| 143 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |
| 144 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |
| 145 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}}, | |
| 146 | + | }) | |
| 147 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 148 | + | interactive.runCtx = context.Background() | |
| 149 | + | ||
| 150 | + | interactive.startTurn(context.Background(), "retry me") | |
| 151 | + | ||
| 152 | + | select { | |
| 153 | + | case req := <-client.retried: | |
| 154 | + | if !requestContainsUserText(req, "retry me") { | |
| 155 | + | t.Fatalf("retried request does not contain original prompt: %#v", req.Messages) | |
| 156 | + | } | |
| 157 | + | case <-time.After(2 * time.Second): | |
| 158 | + | t.Fatal("context-window error did not compact and retry the prompt") | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |
| 163 | + | tests := []struct { | |
| 164 | + | name string | |
| 165 | + | err error | |
| 166 | + | want bool | |
| 167 | + | }{ | |
| 168 | + | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |
| 169 | + | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |
| 170 | + | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |
| 171 | + | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |
| 172 | + | {name: "maximum token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |
| 173 | + | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | for _, tt := range tests { | |
| 177 | + | t.Run(tt.name, func(t *testing.T) { | |
| 178 | + | if got := isContextOverflowError(tt.err); got != tt.want { | |
| 179 | + | t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |
| 180 | + | } | |
| 181 | + | }) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 96 | 185 | func requestContainsUserText(req provider.Request, want string) bool { | |
| 97 | 186 | for _, message := range req.Messages { | |
| 98 | 187 | if message.Role != provider.RoleUser { | |
| @@ -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,6 +94,94 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | |||||
| 93 | } | 94 | } | ||
| 94 | } | 95 | } | ||
| 95 | 96 | ||||
| 97 | + | type contextRecoveryClient struct { | |||
| 98 | + | mu sync.Mutex | |||
| 99 | + | calls int | |||
| 100 | + | retried chan provider.Request | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (c *contextRecoveryClient) Name() string { return "context-recovery-test" } | |||
| 104 | + | ||||
| 105 | + | func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 106 | + | c.mu.Lock() | |||
| 107 | + | c.calls++ | |||
| 108 | + | call := c.calls | |||
| 109 | + | c.mu.Unlock() | |||
| 110 | + | ||||
| 111 | + | out := make(chan provider.Event, 2) | |||
| 112 | + | go func() { | |||
| 113 | + | defer close(out) | |||
| 114 | + | switch call { | |||
| 115 | + | case 1: | |||
| 116 | + | out <- provider.EventDone{ | |||
| 117 | + | Stop: provider.StopError, | |||
| 118 | + | Err: errors.New("provider error: Your input exceeds the context window of this model. Please adjust your input and try again."), | |||
| 119 | + | } | |||
| 120 | + | case 2: | |||
| 121 | + | out <- provider.EventTextDelta{Delta: "summary"} | |||
| 122 | + | out <- provider.EventDone{Stop: provider.StopEnd} | |||
| 123 | + | default: | |||
| 124 | + | c.retried <- req | |||
| 125 | + | out <- provider.EventDone{ | |||
| 126 | + | Stop: provider.StopEnd, | |||
| 127 | + | Message: provider.Message{ | |||
| 128 | + | Role: provider.RoleAssistant, | |||
| 129 | + | Content: []provider.Content{provider.TextBlock{Text: "done"}}, | |||
| 130 | + | }, | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | }() | |||
| 134 | + | return out, nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) { | |||
| 138 | + | client := &contextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 139 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 140 | + | agent.SetMessages([]provider.Message{ | |||
| 141 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |||
| 142 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |||
| 143 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |||
| 144 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |||
| 145 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}}, | |||
| 146 | + | }) | |||
| 147 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |||
| 148 | + | interactive.runCtx = context.Background() | |||
| 149 | + | ||||
| 150 | + | interactive.startTurn(context.Background(), "retry me") | |||
| 151 | + | ||||
| 152 | + | select { | |||
| 153 | + | case req := <-client.retried: | |||
| 154 | + | if !requestContainsUserText(req, "retry me") { | |||
| 155 | + | t.Fatalf("retried request does not contain original prompt: %#v", req.Messages) | |||
| 156 | + | } | |||
| 157 | + | case <-time.After(2 * time.Second): | |||
| 158 | + | t.Fatal("context-window error did not compact and retry the prompt") | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |||
| 163 | + | tests := []struct { | |||
| 164 | + | name string | |||
| 165 | + | err error | |||
| 166 | + | want bool | |||
| 167 | + | }{ | |||
| 168 | + | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |||
| 169 | + | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |||
| 170 | + | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |||
| 171 | + | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |||
| 172 | + | {name: "maximum token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |||
| 173 | + | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | for _, tt := range tests { | |||
| 177 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 178 | + | if got := isContextOverflowError(tt.err); got != tt.want { | |||
| 179 | + | t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |||
| 180 | + | } | |||
| 181 | + | }) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 96 | func requestContainsUserText(req provider.Request, want string) bool { | 185 | func requestContainsUserText(req provider.Request, want string) bool { | ||
| 97 | for _, message := range req.Messages { | 186 | for _, message := range req.Messages { | ||
| 98 | if message.Role != provider.RoleUser { | 187 | if message.Role != provider.RoleUser { | ||
M packages/agent/modes/interactive.go +24 -16
| @@ -5476,13 +5476,13 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 5476 | 5476 | i.statusErr = "" | |
| 5477 | 5477 | } | |
| 5478 | 5478 | } | |
| 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 { | |
| 5479 | + | // Detect responses that reject the current context, either as an | |
| 5480 | + | // HTTP 413 payload limit or a model context-window error. Token- | |
| 5481 | + | // based auto-compact can miss both when metadata is stale or the | |
| 5482 | + | // limit is measured in raw bytes. Re-queue the prompt so it | |
| 5483 | + | // survives the condense pass and trigger one. | |
| 5484 | + | contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err) | |
| 5485 | + | if contextOverflow { | |
| 5486 | 5486 | i.statusErr = "" | |
| 5487 | 5487 | i.queued = append([]string{prompt}, i.queued...) | |
| 5488 | 5488 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ...")) | |
| @@ -5509,7 +5509,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 5509 | 5509 | } | |
| 5510 | 5510 | // If the turn was cancelled or errored, drop the queue so the | |
| 5511 | 5511 | // user isn't bombarded with stale messages after an interrupt. | |
| 5512 | - | if ctx.Err() != nil || err != nil { | |
| 5512 | + | if ctx.Err() != nil || (err != nil && !contextOverflow) { | |
| 5513 | 5513 | i.queued = nil | |
| 5514 | 5514 | if i.agent != nil { | |
| 5515 | 5515 | i.agent.DrainQueuedMessages() | |
| @@ -5535,7 +5535,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 5535 | 5535 | i.startTurn(parent, next) | |
| 5536 | 5536 | case offer: | |
| 5537 | 5537 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | |
| 5538 | - | case payloadTooLarge: | |
| 5538 | + | case contextOverflow: | |
| 5539 | 5539 | i.runCompact(parent, true) | |
| 5540 | 5540 | case shouldAutoCompact: | |
| 5541 | 5541 | i.runCompact(parent, true) | |
| @@ -5614,17 +5614,25 @@ func autoCompactNoteLine(th tui.Theme, msg string) string { | |||
| 5614 | 5614 | return " " + th.FG256(th.Warning, "⚠ "+msg) | |
| 5615 | 5615 | } | |
| 5616 | 5616 | ||
| 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 { | |
| 5617 | + | // isContextOverflowError matches provider responses that reject the | |
| 5618 | + | // current request because its raw payload or tokenized input is too | |
| 5619 | + | // large. Providers surface this as either HTTP 413 or semantic context | |
| 5620 | + | // errors with differing codes and messages. | |
| 5621 | + | func isContextOverflowError(err error) bool { | |
| 5623 | 5622 | if err == nil { | |
| 5624 | 5623 | return false | |
| 5625 | 5624 | } | |
| 5626 | 5625 | 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") | |
| 5626 | + | return strings.Contains(msg, "http 413") || | |
| 5627 | + | strings.Contains(msg, " 413") || | |
| 5628 | + | strings.HasPrefix(msg, "413 ") || | |
| 5629 | + | strings.Contains(msg, "payload too large") || | |
| 5630 | + | strings.Contains(msg, "request entity too large") || | |
| 5631 | + | strings.Contains(msg, "input exceeds the context window") || | |
| 5632 | + | strings.Contains(msg, "context window exceeded") || | |
| 5633 | + | strings.Contains(msg, "maximum context length") || | |
| 5634 | + | strings.Contains(msg, "context_length_exceeded") || | |
| 5635 | + | strings.Contains(msg, "exceeds the maximum number of tokens") | |
| 5628 | 5636 | } | |
| 5629 | 5637 | ||
| 5630 | 5638 | const defaultAutoCompactThreshold = 85 | |
| @@ -5476,13 +5476,13 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 5476 | i.statusErr = "" | 5476 | i.statusErr = "" | ||
| 5477 | } | 5477 | } | ||
| 5478 | } | 5478 | } | ||
| 5479 | - | // Detect HTTP 413 "payload too large" responses. The provider | 5479 | + | // Detect responses that reject the current context, either as an |
| 5480 | - | // rejected the request because the request body exceeded its | 5480 | + | // HTTP 413 payload limit or a model context-window error. Token- |
| 5481 | - | // per-request limit. Token-based auto-compact can miss this | 5481 | + | // 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 | 5482 | + | // limit is measured in raw bytes. Re-queue the prompt so it |
| 5483 | - | // prompt so it survives the condense pass and trigger one. | 5483 | + | // survives the condense pass and trigger one. |
| 5484 | - | payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err) | 5484 | + | contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err) |
| 5485 | - | if payloadTooLarge { | 5485 | + | if contextOverflow { |
| 5486 | i.statusErr = "" | 5486 | i.statusErr = "" | ||
| 5487 | i.queued = append([]string{prompt}, i.queued...) | 5487 | i.queued = append([]string{prompt}, i.queued...) | ||
| 5488 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ...")) | 5488 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ...")) | ||
| @@ -5509,7 +5509,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 5509 | } | 5509 | } | ||
| 5510 | // If the turn was cancelled or errored, drop the queue so the | 5510 | // If the turn was cancelled or errored, drop the queue so the | ||
| 5511 | // user isn't bombarded with stale messages after an interrupt. | 5511 | // user isn't bombarded with stale messages after an interrupt. | ||
| 5512 | - | if ctx.Err() != nil || err != nil { | 5512 | + | if ctx.Err() != nil || (err != nil && !contextOverflow) { |
| 5513 | i.queued = nil | 5513 | i.queued = nil | ||
| 5514 | if i.agent != nil { | 5514 | if i.agent != nil { | ||
| 5515 | i.agent.DrainQueuedMessages() | 5515 | i.agent.DrainQueuedMessages() | ||
| @@ -5535,7 +5535,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 5535 | i.startTurn(parent, next) | 5535 | i.startTurn(parent, next) | ||
| 5536 | case offer: | 5536 | case offer: | ||
| 5537 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | 5537 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | ||
| 5538 | - | case payloadTooLarge: | 5538 | + | case contextOverflow: |
| 5539 | i.runCompact(parent, true) | 5539 | i.runCompact(parent, true) | ||
| 5540 | case shouldAutoCompact: | 5540 | case shouldAutoCompact: | ||
| 5541 | i.runCompact(parent, true) | 5541 | i.runCompact(parent, true) | ||
| @@ -5614,17 +5614,25 @@ func autoCompactNoteLine(th tui.Theme, msg string) string { | |||||
| 5614 | return " " + th.FG256(th.Warning, "⚠ "+msg) | 5614 | return " " + th.FG256(th.Warning, "⚠ "+msg) | ||
| 5615 | } | 5615 | } | ||
| 5616 | 5616 | ||||
| 5617 | - | // isPayloadTooLargeError matches HTTP 413 responses surfaced by the | 5617 | + | // isContextOverflowError matches provider responses that reject the |
| 5618 | - | // provider clients. The error formatting differs slightly between | 5618 | + | // current request because its raw payload or tokenized input is too |
| 5619 | - | // providers (anthropic and openai both prepend the status code), so | 5619 | + | // large. Providers surface this as either HTTP 413 or semantic context |
| 5620 | - | // we look for the canonical 413 marker as well as the conventional | 5620 | + | // errors with differing codes and messages. |
| 5621 | - | // 'payload too large' phrase. | 5621 | + | func isContextOverflowError(err error) bool { |
| 5622 | - | func isPayloadTooLargeError(err error) bool { | |||
| 5623 | if err == nil { | 5622 | if err == nil { | ||
| 5624 | return false | 5623 | return false | ||
| 5625 | } | 5624 | } | ||
| 5626 | msg := strings.ToLower(err.Error()) | 5625 | 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") | 5626 | + | return strings.Contains(msg, "http 413") || |
| 5627 | + | strings.Contains(msg, " 413") || | |||
| 5628 | + | strings.HasPrefix(msg, "413 ") || | |||
| 5629 | + | strings.Contains(msg, "payload too large") || | |||
| 5630 | + | strings.Contains(msg, "request entity too large") || | |||
| 5631 | + | strings.Contains(msg, "input exceeds the context window") || | |||
| 5632 | + | strings.Contains(msg, "context window exceeded") || | |||
| 5633 | + | strings.Contains(msg, "maximum context length") || | |||
| 5634 | + | strings.Contains(msg, "context_length_exceeded") || | |||
| 5635 | + | strings.Contains(msg, "exceeds the maximum number of tokens") | |||
| 5628 | } | 5636 | } | ||
| 5629 | 5637 | ||||
| 5630 | const defaultAutoCompactThreshold = 85 | 5638 | 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 | ||||