Make context overflow recovery safe
- commit
- 2ed47755fb34ca77bc25969d34c555e7c876e338
- author
- patriceckhart · · 2026-07-29 11:03 UTC
- parent
- de925da
- signature
- unsigned
2 files · +204 -28
M packages/agent/modes/compact_queue_test.go +138 -10
| @@ -94,10 +94,57 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | |||
| 94 | 94 | } | |
| 95 | 95 | } | |
| 96 | 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 | + | ||
| 97 | 143 | type contextRecoveryClient struct { | |
| 98 | - | mu sync.Mutex | |
| 99 | - | calls int | |
| 100 | - | retried chan provider.Request | |
| 144 | + | mu sync.Mutex | |
| 145 | + | calls int | |
| 146 | + | retried chan provider.Request | |
| 147 | + | overflowRetry bool | |
| 101 | 148 | } | |
| 102 | 149 | ||
| 103 | 150 | func (c *contextRecoveryClient) Name() string { return "context-recovery-test" } | |
| @@ -122,6 +169,13 @@ func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) | |||
| 122 | 169 | out <- provider.EventDone{Stop: provider.StopEnd} | |
| 123 | 170 | default: | |
| 124 | 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 | + | } | |
| 125 | 179 | out <- provider.EventDone{ | |
| 126 | 180 | Stop: provider.StopEnd, | |
| 127 | 181 | Message: provider.Message{ | |
| @@ -134,7 +188,7 @@ func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) | |||
| 134 | 188 | return out, nil | |
| 135 | 189 | } | |
| 136 | 190 | ||
| 137 | - | func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) { | |
| 191 | + | func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) { | |
| 138 | 192 | client := &contextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 139 | 193 | agent := core.NewAgent(client, "test-model", "", nil) | |
| 140 | 194 | agent.SetMessages([]provider.Message{ | |
| @@ -147,18 +201,71 @@ func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) { | |||
| 147 | 201 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 148 | 202 | interactive.runCtx = context.Background() | |
| 149 | 203 | ||
| 150 | - | interactive.startTurn(context.Background(), "retry me") | |
| 204 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")} | |
| 205 | + | interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image}) | |
| 151 | 206 | ||
| 152 | 207 | select { | |
| 153 | 208 | case req := <-client.retried: | |
| 154 | - | if !requestContainsUserText(req, "retry me") { | |
| 155 | - | t.Fatalf("retried request does not contain original prompt: %#v", req.Messages) | |
| 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) | |
| 156 | 214 | } | |
| 157 | 215 | case <-time.After(2 * time.Second): | |
| 158 | 216 | t.Fatal("context-window error did not compact and retry the prompt") | |
| 159 | 217 | } | |
| 160 | 218 | } | |
| 161 | 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 | + | ||
| 162 | 269 | func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |
| 163 | 270 | tests := []struct { | |
| 164 | 271 | name string | |
| @@ -169,7 +276,8 @@ func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |||
| 169 | 276 | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |
| 170 | 277 | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |
| 171 | 278 | {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}, | |
| 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}, | |
| 173 | 281 | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |
| 174 | 282 | } | |
| 175 | 283 | ||
| @@ -183,15 +291,35 @@ func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |||
| 183 | 291 | } | |
| 184 | 292 | ||
| 185 | 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 | |
| 186 | 299 | for _, message := range req.Messages { | |
| 187 | 300 | if message.Role != provider.RoleUser { | |
| 188 | 301 | continue | |
| 189 | 302 | } | |
| 190 | 303 | for _, content := range message.Content { | |
| 191 | 304 | if text, ok := content.(provider.TextBlock); ok && text.Text == want { | |
| 192 | - | 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++ | |
| 193 | 321 | } | |
| 194 | 322 | } | |
| 195 | 323 | } | |
| 196 | - | return false | |
| 324 | + | return count | |
| 197 | 325 | } | |
| @@ -94,10 +94,57 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | |||||
| 94 | } | 94 | } | ||
| 95 | } | 95 | } | ||
| 96 | 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 | + | ||||
| 97 | type contextRecoveryClient struct { | 143 | type contextRecoveryClient struct { | ||
| 98 | - | mu sync.Mutex | 144 | + | mu sync.Mutex |
| 99 | - | calls int | 145 | + | calls int |
| 100 | - | retried chan provider.Request | 146 | + | retried chan provider.Request |
| 147 | + | overflowRetry bool | |||
| 101 | } | 148 | } | ||
| 102 | 149 | ||||
| 103 | func (c *contextRecoveryClient) Name() string { return "context-recovery-test" } | 150 | func (c *contextRecoveryClient) Name() string { return "context-recovery-test" } | ||
| @@ -122,6 +169,13 @@ func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) | |||||
| 122 | out <- provider.EventDone{Stop: provider.StopEnd} | 169 | out <- provider.EventDone{Stop: provider.StopEnd} | ||
| 123 | default: | 170 | default: | ||
| 124 | c.retried <- req | 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 | + | } | |||
| 125 | out <- provider.EventDone{ | 179 | out <- provider.EventDone{ | ||
| 126 | Stop: provider.StopEnd, | 180 | Stop: provider.StopEnd, | ||
| 127 | Message: provider.Message{ | 181 | Message: provider.Message{ | ||
| @@ -134,7 +188,7 @@ func (c *contextRecoveryClient) Stream(_ context.Context, req provider.Request) | |||||
| 134 | return out, nil | 188 | return out, nil | ||
| 135 | } | 189 | } | ||
| 136 | 190 | ||||
| 137 | - | func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) { | 191 | + | func TestContextWindowErrorCompactsAndRetriesPromptOnce(t *testing.T) { |
| 138 | client := &contextRecoveryClient{retried: make(chan provider.Request, 1)} | 192 | client := &contextRecoveryClient{retried: make(chan provider.Request, 1)} | ||
| 139 | agent := core.NewAgent(client, "test-model", "", nil) | 193 | agent := core.NewAgent(client, "test-model", "", nil) | ||
| 140 | agent.SetMessages([]provider.Message{ | 194 | agent.SetMessages([]provider.Message{ | ||
| @@ -147,18 +201,71 @@ func TestContextWindowErrorCompactsAndRetriesPrompt(t *testing.T) { | |||||
| 147 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | 201 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | ||
| 148 | interactive.runCtx = context.Background() | 202 | interactive.runCtx = context.Background() | ||
| 149 | 203 | ||||
| 150 | - | interactive.startTurn(context.Background(), "retry me") | 204 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image-data")} |
| 205 | + | interactive.startTurnWithImages(context.Background(), "retry me", []provider.ImageBlock{image}) | |||
| 151 | 206 | ||||
| 152 | select { | 207 | select { | ||
| 153 | case req := <-client.retried: | 208 | case req := <-client.retried: | ||
| 154 | - | if !requestContainsUserText(req, "retry me") { | 209 | + | if got := requestUserTextCount(req, "retry me"); got != 1 { |
| 155 | - | t.Fatalf("retried request does not contain original prompt: %#v", req.Messages) | 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) | |||
| 156 | } | 214 | } | ||
| 157 | case <-time.After(2 * time.Second): | 215 | case <-time.After(2 * time.Second): | ||
| 158 | t.Fatal("context-window error did not compact and retry the prompt") | 216 | t.Fatal("context-window error did not compact and retry the prompt") | ||
| 159 | } | 217 | } | ||
| 160 | } | 218 | } | ||
| 161 | 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 | + | ||||
| 162 | func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | 269 | func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | ||
| 163 | tests := []struct { | 270 | tests := []struct { | ||
| 164 | name string | 271 | name string | ||
| @@ -169,7 +276,8 @@ func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |||||
| 169 | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | 276 | {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}, | 277 | {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}, | 278 | {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}, | 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}, | |||
| 173 | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | 281 | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | ||
| 174 | } | 282 | } | ||
| 175 | 283 | ||||
| @@ -183,15 +291,35 @@ func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |||||
| 183 | } | 291 | } | ||
| 184 | 292 | ||||
| 185 | 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 | |||
| 186 | for _, message := range req.Messages { | 299 | for _, message := range req.Messages { | ||
| 187 | if message.Role != provider.RoleUser { | 300 | if message.Role != provider.RoleUser { | ||
| 188 | continue | 301 | continue | ||
| 189 | } | 302 | } | ||
| 190 | for _, content := range message.Content { | 303 | for _, content := range message.Content { | ||
| 191 | if text, ok := content.(provider.TextBlock); ok && text.Text == want { | 304 | if text, ok := content.(provider.TextBlock); ok && text.Text == want { | ||
| 192 | - | 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++ | |||
| 193 | } | 321 | } | ||
| 194 | } | 322 | } | ||
| 195 | } | 323 | } | ||
| 196 | - | return false | 324 | + | return count |
| 197 | } | 325 | } | ||
M packages/agent/modes/interactive.go +66 -18
| @@ -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 | |
| @@ -5479,12 +5526,13 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 5479 | 5526 | // Detect responses that reject the current context, either as an | |
| 5480 | 5527 | // HTTP 413 payload limit or a model context-window error. Token- | |
| 5481 | 5528 | // 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. | |
| 5529 | + | // limit is measured in raw bytes. Compact once, then continue the | |
| 5530 | + | // user message already present in the transcript. | |
| 5484 | 5531 | contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err) | |
| 5485 | - | if contextOverflow { | |
| 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 && !contextOverflow) { | |
| 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 contextOverflow: | |
| 5586 | + | case recoverContextOverflow: | |
| 5539 | 5587 | i.runCompact(parent, true) | |
| 5540 | 5588 | case shouldAutoCompact: | |
| 5541 | 5589 | i.runCompact(parent, true) | |
| @@ -5632,7 +5680,7 @@ func isContextOverflowError(err error) bool { | |||
| 5632 | 5680 | strings.Contains(msg, "context window exceeded") || | |
| 5633 | 5681 | strings.Contains(msg, "maximum context length") || | |
| 5634 | 5682 | strings.Contains(msg, "context_length_exceeded") || | |
| 5635 | - | strings.Contains(msg, "exceeds the maximum number of tokens") | |
| 5683 | + | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) | |
| 5636 | 5684 | } | |
| 5637 | 5685 | ||
| 5638 | 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 | ||
| @@ -5479,12 +5526,13 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 5479 | // Detect responses that reject the current context, either as an | 5526 | // Detect responses that reject the current context, either as an | ||
| 5480 | // HTTP 413 payload limit or a model context-window error. Token- | 5527 | // HTTP 413 payload limit or a model context-window error. Token- | ||
| 5481 | // based auto-compact can miss both when metadata is stale or the | 5528 | // 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 | 5529 | + | // limit is measured in raw bytes. Compact once, then continue the |
| 5483 | - | // survives the condense pass and trigger one. | 5530 | + | // user message already present in the transcript. |
| 5484 | contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err) | 5531 | contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err) | ||
| 5485 | - | if contextOverflow { | 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 && !contextOverflow) { | 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 contextOverflow: | 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) | ||
| @@ -5632,7 +5680,7 @@ func isContextOverflowError(err error) bool { | |||||
| 5632 | strings.Contains(msg, "context window exceeded") || | 5680 | strings.Contains(msg, "context window exceeded") || | ||
| 5633 | strings.Contains(msg, "maximum context length") || | 5681 | strings.Contains(msg, "maximum context length") || | ||
| 5634 | strings.Contains(msg, "context_length_exceeded") || | 5682 | strings.Contains(msg, "context_length_exceeded") || | ||
| 5635 | - | strings.Contains(msg, "exceeds the maximum number of tokens") | 5683 | + | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) |
| 5636 | } | 5684 | } | ||
| 5637 | 5685 | ||||
| 5638 | const defaultAutoCompactThreshold = 85 | 5686 | const defaultAutoCompactThreshold = 85 | ||