fix(subagents): recover from context limits
- commit
- 1c1fef041685b6719ec9d30db55c2f7fc7ec5f35
- author
- brice · · 2026-08-06 08:42 UTC
- committer
- brice · · 2026-08-06 09:18 UTC
- parent
- 42a5079
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +490 -51
M docs/subagents.md +2 -0
| @@ -94,6 +94,8 @@ The worker and supervisor communicate over newline-delimited JSON. Current messa | |||
| 94 | 94 | ||
| 95 | 95 | A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references: | |
| 96 | 96 | ||
| 97 | + | If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error. | |
| 98 | + | ||
| 97 | 99 | ```text | |
| 98 | 100 | subagent://<id> | |
| 99 | 101 | subagent://<id>/history | |
| @@ -94,6 +94,8 @@ The worker and supervisor communicate over newline-delimited JSON. Current messa | |||||
| 94 | 94 | ||||
| 95 | A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references: | 95 | A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references: | ||
| 96 | 96 | ||||
| 97 | + | If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error. | |||
| 98 | + | ||||
| 97 | ```text | 99 | ```text | ||
| 98 | subagent://<id> | 100 | subagent://<id> | ||
| 99 | subagent://<id>/history | 101 | subagent://<id>/history | ||
M packages/agent/modes/compact_queue_test.go +0 -24
| @@ -430,30 +430,6 @@ func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) { | |||
| 430 | 430 | } | |
| 431 | 431 | } | |
| 432 | 432 | ||
| 433 | - | func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |
| 434 | - | tests := []struct { | |
| 435 | - | name string | |
| 436 | - | err error | |
| 437 | - | want bool | |
| 438 | - | }{ | |
| 439 | - | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |
| 440 | - | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |
| 441 | - | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |
| 442 | - | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |
| 443 | - | {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |
| 444 | - | {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false}, | |
| 445 | - | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | for _, tt := range tests { | |
| 449 | - | t.Run(tt.name, func(t *testing.T) { | |
| 450 | - | if got := isContextOverflowError(tt.err); got != tt.want { | |
| 451 | - | t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |
| 452 | - | } | |
| 453 | - | }) | |
| 454 | - | } | |
| 455 | - | } | |
| 456 | - | ||
| 457 | 433 | func requestContainsUserText(req provider.Request, want string) bool { | |
| 458 | 434 | return requestUserTextCount(req, want) > 0 | |
| 459 | 435 | } | |
| @@ -430,30 +430,6 @@ func TestContextWindowRecoveryStopsAfterOneRetry(t *testing.T) { | |||||
| 430 | } | 430 | } | ||
| 431 | } | 431 | } | ||
| 432 | 432 | ||||
| 433 | - | func TestContextOverflowErrorRecognizesProviderMessages(t *testing.T) { | |||
| 434 | - | tests := []struct { | |||
| 435 | - | name string | |||
| 436 | - | err error | |||
| 437 | - | want bool | |||
| 438 | - | }{ | |||
| 439 | - | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |||
| 440 | - | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |||
| 441 | - | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |||
| 442 | - | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |||
| 443 | - | {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |||
| 444 | - | {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false}, | |||
| 445 | - | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |||
| 446 | - | } | |||
| 447 | - | ||||
| 448 | - | for _, tt := range tests { | |||
| 449 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 450 | - | if got := isContextOverflowError(tt.err); got != tt.want { | |||
| 451 | - | t.Fatalf("isContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |||
| 452 | - | } | |||
| 453 | - | }) | |||
| 454 | - | } | |||
| 455 | - | } | |||
| 456 | - | ||||
| 457 | func requestContainsUserText(req provider.Request, want string) bool { | 433 | func requestContainsUserText(req provider.Request, want string) bool { | ||
| 458 | return requestUserTextCount(req, want) > 0 | 434 | return requestUserTextCount(req, want) > 0 | ||
| 459 | } | 435 | } | ||
M packages/agent/modes/interactive.go +1 -22
| @@ -6859,7 +6859,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6859 | 6859 | // based auto-compact can miss both when metadata is stale or the | |
| 6860 | 6860 | // limit is measured in raw bytes. Compact once, then continue the | |
| 6861 | 6861 | // user message already present in the transcript. | |
| 6862 | - | contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err) | |
| 6862 | + | contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err) | |
| 6863 | 6863 | recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted | |
| 6864 | 6864 | if recoverContextOverflow { | |
| 6865 | 6865 | i.statusErr = "" | |
| @@ -7033,27 +7033,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string { | |||
| 7033 | 7033 | return " " + th.FGColor(th.Warning, "⚠ "+msg) | |
| 7034 | 7034 | } | |
| 7035 | 7035 | ||
| 7036 | - | // isContextOverflowError matches provider responses that reject the | |
| 7037 | - | // current request because its raw payload or tokenized input is too | |
| 7038 | - | // large. Providers surface this as either HTTP 413 or semantic context | |
| 7039 | - | // errors with differing codes and messages. | |
| 7040 | - | func isContextOverflowError(err error) bool { | |
| 7041 | - | if err == nil { | |
| 7042 | - | return false | |
| 7043 | - | } | |
| 7044 | - | msg := strings.ToLower(err.Error()) | |
| 7045 | - | return strings.Contains(msg, "http 413") || | |
| 7046 | - | strings.Contains(msg, " 413") || | |
| 7047 | - | strings.HasPrefix(msg, "413 ") || | |
| 7048 | - | strings.Contains(msg, "payload too large") || | |
| 7049 | - | strings.Contains(msg, "request entity too large") || | |
| 7050 | - | strings.Contains(msg, "input exceeds the context window") || | |
| 7051 | - | strings.Contains(msg, "context window exceeded") || | |
| 7052 | - | strings.Contains(msg, "maximum context length") || | |
| 7053 | - | strings.Contains(msg, "context_length_exceeded") || | |
| 7054 | - | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) | |
| 7055 | - | } | |
| 7056 | - | ||
| 7057 | 7036 | const defaultAutoCompactThreshold = 85 | |
| 7058 | 7037 | ||
| 7059 | 7038 | func normalizeAutoCompactThreshold(threshold *int) int { | |
| @@ -6859,7 +6859,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6859 | // based auto-compact can miss both when metadata is stale or the | 6859 | // based auto-compact can miss both when metadata is stale or the | ||
| 6860 | // limit is measured in raw bytes. Compact once, then continue the | 6860 | // limit is measured in raw bytes. Compact once, then continue the | ||
| 6861 | // user message already present in the transcript. | 6861 | // user message already present in the transcript. | ||
| 6862 | - | contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err) | 6862 | + | contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err) |
| 6863 | recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted | 6863 | recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted | ||
| 6864 | if recoverContextOverflow { | 6864 | if recoverContextOverflow { | ||
| 6865 | i.statusErr = "" | 6865 | i.statusErr = "" | ||
| @@ -7033,27 +7033,6 @@ func autoCompactNoteLine(th tui.Theme, msg string) string { | |||||
| 7033 | return " " + th.FGColor(th.Warning, "⚠ "+msg) | 7033 | return " " + th.FGColor(th.Warning, "⚠ "+msg) | ||
| 7034 | } | 7034 | } | ||
| 7035 | 7035 | ||||
| 7036 | - | // isContextOverflowError matches provider responses that reject the | |||
| 7037 | - | // current request because its raw payload or tokenized input is too | |||
| 7038 | - | // large. Providers surface this as either HTTP 413 or semantic context | |||
| 7039 | - | // errors with differing codes and messages. | |||
| 7040 | - | func isContextOverflowError(err error) bool { | |||
| 7041 | - | if err == nil { | |||
| 7042 | - | return false | |||
| 7043 | - | } | |||
| 7044 | - | msg := strings.ToLower(err.Error()) | |||
| 7045 | - | return strings.Contains(msg, "http 413") || | |||
| 7046 | - | strings.Contains(msg, " 413") || | |||
| 7047 | - | strings.HasPrefix(msg, "413 ") || | |||
| 7048 | - | strings.Contains(msg, "payload too large") || | |||
| 7049 | - | strings.Contains(msg, "request entity too large") || | |||
| 7050 | - | strings.Contains(msg, "input exceeds the context window") || | |||
| 7051 | - | strings.Contains(msg, "context window exceeded") || | |||
| 7052 | - | strings.Contains(msg, "maximum context length") || | |||
| 7053 | - | strings.Contains(msg, "context_length_exceeded") || | |||
| 7054 | - | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) | |||
| 7055 | - | } | |||
| 7056 | - | ||||
| 7057 | const defaultAutoCompactThreshold = 85 | 7036 | const defaultAutoCompactThreshold = 85 | ||
| 7058 | 7037 | ||||
| 7059 | func normalizeAutoCompactThreshold(threshold *int) int { | 7038 | func normalizeAutoCompactThreshold(threshold *int) int { | ||
M packages/agent/modes/rescue_dialog.go +1 -1
| @@ -233,7 +233,7 @@ func classifyRescueError(err error) (bool, string) { | |||
| 233 | 233 | ||
| 234 | 234 | // Don't trigger on context overflow; that path already has its own | |
| 235 | 235 | // auto-compact handling. | |
| 236 | - | if isContextOverflowError(err) { | |
| 236 | + | if provider.IsContextOverflowError(err) { | |
| 237 | 237 | return false, "" | |
| 238 | 238 | } | |
| 239 | 239 | ||
| @@ -233,7 +233,7 @@ func classifyRescueError(err error) (bool, string) { | |||||
| 233 | 233 | ||||
| 234 | // Don't trigger on context overflow; that path already has its own | 234 | // Don't trigger on context overflow; that path already has its own | ||
| 235 | // auto-compact handling. | 235 | // auto-compact handling. | ||
| 236 | - | if isContextOverflowError(err) { | 236 | + | if provider.IsContextOverflowError(err) { |
| 237 | return false, "" | 237 | return false, "" | ||
| 238 | } | 238 | } | ||
| 239 | 239 | ||||
M packages/agent/subagent_worker.go +67 -4
| @@ -201,11 +201,14 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||
| 201 | 201 | em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID}) | |
| 202 | 202 | ||
| 203 | 203 | sink := func(ev core.AgentEvent) { | |
| 204 | - | em.emit(ev.Type(), modes.EventToJSON(ev)) | |
| 204 | + | em.emit(ev.Type(), subagentEventData(ev)) | |
| 205 | 205 | } | |
| 206 | 206 | ||
| 207 | - | start := len(ag.Messages()) | |
| 208 | - | err := ag.Prompt(c, prompt, nil, sink) | |
| 207 | + | var persistCompaction func([]provider.Message) error | |
| 208 | + | if sess != nil { | |
| 209 | + | persistCompaction = sess.AppendCompaction | |
| 210 | + | } | |
| 211 | + | start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction) | |
| 209 | 212 | WriteNewTranscript(ag, sess, start) | |
| 210 | 213 | ||
| 211 | 214 | output := finalAssistantText(ag.Messages()[start:]) | |
| @@ -231,7 +234,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||
| 231 | 234 | em.emit("turn_end", map[string]any{ | |
| 232 | 235 | "step": step, | |
| 233 | 236 | "turn_id": turnID, | |
| 234 | - | "error": errString(err), | |
| 237 | + | "error": turnErrorMessage(err), | |
| 235 | 238 | }) | |
| 236 | 239 | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | |
| 237 | 240 | ||
| @@ -402,6 +405,14 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) { | |||
| 402 | 405 | } | |
| 403 | 406 | } | |
| 404 | 407 | ||
| 408 | + | func subagentEventData(ev core.AgentEvent) map[string]any { | |
| 409 | + | data := modes.EventToJSON(ev) | |
| 410 | + | if turnEnd, ok := ev.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |
| 411 | + | data["error"] = subagentContextLimitMessage | |
| 412 | + | } | |
| 413 | + | return data | |
| 414 | + | } | |
| 415 | + | ||
| 405 | 416 | func canonicalWorkerEvent(typ string) string { | |
| 406 | 417 | switch typ { | |
| 407 | 418 | case "agent_ready": | |
| @@ -423,6 +434,48 @@ func canonicalWorkerEvent(typ string) string { | |||
| 423 | 434 | } | |
| 424 | 435 | } | |
| 425 | 436 | ||
| 437 | + | const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context" | |
| 438 | + | ||
| 439 | + | // promptWithContextRecovery appends prompt and runs it once. When the provider | |
| 440 | + | // rejects that request for exceeding its context limit, it compacts the current | |
| 441 | + | // transcript and continues the already-appended prompt exactly once. | |
| 442 | + | // | |
| 443 | + | // The returned index marks the first message that can contribute to the turn | |
| 444 | + | // result or be appended after a compaction checkpoint. Compact replaces the | |
| 445 | + | // transcript, so callers must not reuse the index captured before Prompt. | |
| 446 | + | func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) { | |
| 447 | + | outputStart = len(ag.Messages()) | |
| 448 | + | if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) { | |
| 449 | + | return outputStart, err | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | messages := ag.Messages() | |
| 453 | + | if len(messages) <= 1 { | |
| 454 | + | // The only message is the failed prompt. Summarizing it would replace | |
| 455 | + | // the user's task with an LLM-generated paraphrase, so report the | |
| 456 | + | // provider's limit and ask the caller to narrow the task instead. | |
| 457 | + | return outputStart, err | |
| 458 | + | } | |
| 459 | + | keepTail := min(4, len(messages)-1) | |
| 460 | + | if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil { | |
| 461 | + | return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | compacted := ag.Messages() | |
| 465 | + | if persistCompaction != nil { | |
| 466 | + | if persistErr := persistCompaction(compacted); persistErr != nil { | |
| 467 | + | // Keep the original history plus the user's pending task so the | |
| 468 | + | // outer transcript flush can persist that task without committing | |
| 469 | + | // an in-memory compaction that the session did not receive. | |
| 470 | + | ag.SetMessages(messages) | |
| 471 | + | return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr) | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | outputStart = len(compacted) | |
| 476 | + | return outputStart, ag.Continue(ctx, sink) | |
| 477 | + | } | |
| 478 | + | ||
| 426 | 479 | func finalAssistantText(messages []provider.Message) string { | |
| 427 | 480 | var out strings.Builder | |
| 428 | 481 | for _, message := range messages { | |
| @@ -498,9 +551,19 @@ func resultErrorPayload(err error) map[string]any { | |||
| 498 | 551 | if err == nil { | |
| 499 | 552 | return nil | |
| 500 | 553 | } | |
| 554 | + | if provider.IsContextOverflowError(err) { | |
| 555 | + | return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage} | |
| 556 | + | } | |
| 501 | 557 | return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)} | |
| 502 | 558 | } | |
| 503 | 559 | ||
| 560 | + | func turnErrorMessage(err error) string { | |
| 561 | + | if provider.IsContextOverflowError(err) { | |
| 562 | + | return subagentContextLimitMessage | |
| 563 | + | } | |
| 564 | + | return errString(err) | |
| 565 | + | } | |
| 566 | + | ||
| 504 | 567 | func errString(err error) string { | |
| 505 | 568 | if err == nil { | |
| 506 | 569 | return "" | |
| @@ -201,11 +201,14 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||||
| 201 | em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID}) | 201 | em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID}) | ||
| 202 | 202 | ||||
| 203 | sink := func(ev core.AgentEvent) { | 203 | sink := func(ev core.AgentEvent) { | ||
| 204 | - | em.emit(ev.Type(), modes.EventToJSON(ev)) | 204 | + | em.emit(ev.Type(), subagentEventData(ev)) |
| 205 | } | 205 | } | ||
| 206 | 206 | ||||
| 207 | - | start := len(ag.Messages()) | 207 | + | var persistCompaction func([]provider.Message) error |
| 208 | - | err := ag.Prompt(c, prompt, nil, sink) | 208 | + | if sess != nil { |
| 209 | + | persistCompaction = sess.AppendCompaction | |||
| 210 | + | } | |||
| 211 | + | start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction) | |||
| 209 | WriteNewTranscript(ag, sess, start) | 212 | WriteNewTranscript(ag, sess, start) | ||
| 210 | 213 | ||||
| 211 | output := finalAssistantText(ag.Messages()[start:]) | 214 | output := finalAssistantText(ag.Messages()[start:]) | ||
| @@ -231,7 +234,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error | |||||
| 231 | em.emit("turn_end", map[string]any{ | 234 | em.emit("turn_end", map[string]any{ | ||
| 232 | "step": step, | 235 | "step": step, | ||
| 233 | "turn_id": turnID, | 236 | "turn_id": turnID, | ||
| 234 | - | "error": errString(err), | 237 | + | "error": turnErrorMessage(err), |
| 235 | }) | 238 | }) | ||
| 236 | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | 239 | em.emit("agent.idle", map[string]any{"turn_id": turnID}) | ||
| 237 | 240 | ||||
| @@ -402,6 +405,14 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) { | |||||
| 402 | } | 405 | } | ||
| 403 | } | 406 | } | ||
| 404 | 407 | ||||
| 408 | + | func subagentEventData(ev core.AgentEvent) map[string]any { | |||
| 409 | + | data := modes.EventToJSON(ev) | |||
| 410 | + | if turnEnd, ok := ev.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |||
| 411 | + | data["error"] = subagentContextLimitMessage | |||
| 412 | + | } | |||
| 413 | + | return data | |||
| 414 | + | } | |||
| 415 | + | ||||
| 405 | func canonicalWorkerEvent(typ string) string { | 416 | func canonicalWorkerEvent(typ string) string { | ||
| 406 | switch typ { | 417 | switch typ { | ||
| 407 | case "agent_ready": | 418 | case "agent_ready": | ||
| @@ -423,6 +434,48 @@ func canonicalWorkerEvent(typ string) string { | |||||
| 423 | } | 434 | } | ||
| 424 | } | 435 | } | ||
| 425 | 436 | ||||
| 437 | + | const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context" | |||
| 438 | + | ||||
| 439 | + | // promptWithContextRecovery appends prompt and runs it once. When the provider | |||
| 440 | + | // rejects that request for exceeding its context limit, it compacts the current | |||
| 441 | + | // transcript and continues the already-appended prompt exactly once. | |||
| 442 | + | // | |||
| 443 | + | // The returned index marks the first message that can contribute to the turn | |||
| 444 | + | // result or be appended after a compaction checkpoint. Compact replaces the | |||
| 445 | + | // transcript, so callers must not reuse the index captured before Prompt. | |||
| 446 | + | func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) { | |||
| 447 | + | outputStart = len(ag.Messages()) | |||
| 448 | + | if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) { | |||
| 449 | + | return outputStart, err | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | messages := ag.Messages() | |||
| 453 | + | if len(messages) <= 1 { | |||
| 454 | + | // The only message is the failed prompt. Summarizing it would replace | |||
| 455 | + | // the user's task with an LLM-generated paraphrase, so report the | |||
| 456 | + | // provider's limit and ask the caller to narrow the task instead. | |||
| 457 | + | return outputStart, err | |||
| 458 | + | } | |||
| 459 | + | keepTail := min(4, len(messages)-1) | |||
| 460 | + | if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil { | |||
| 461 | + | return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr) | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | compacted := ag.Messages() | |||
| 465 | + | if persistCompaction != nil { | |||
| 466 | + | if persistErr := persistCompaction(compacted); persistErr != nil { | |||
| 467 | + | // Keep the original history plus the user's pending task so the | |||
| 468 | + | // outer transcript flush can persist that task without committing | |||
| 469 | + | // an in-memory compaction that the session did not receive. | |||
| 470 | + | ag.SetMessages(messages) | |||
| 471 | + | return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr) | |||
| 472 | + | } | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | outputStart = len(compacted) | |||
| 476 | + | return outputStart, ag.Continue(ctx, sink) | |||
| 477 | + | } | |||
| 478 | + | ||||
| 426 | func finalAssistantText(messages []provider.Message) string { | 479 | func finalAssistantText(messages []provider.Message) string { | ||
| 427 | var out strings.Builder | 480 | var out strings.Builder | ||
| 428 | for _, message := range messages { | 481 | for _, message := range messages { | ||
| @@ -498,9 +551,19 @@ func resultErrorPayload(err error) map[string]any { | |||||
| 498 | if err == nil { | 551 | if err == nil { | ||
| 499 | return nil | 552 | return nil | ||
| 500 | } | 553 | } | ||
| 554 | + | if provider.IsContextOverflowError(err) { | |||
| 555 | + | return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage} | |||
| 556 | + | } | |||
| 501 | return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)} | 557 | return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)} | ||
| 502 | } | 558 | } | ||
| 503 | 559 | ||||
| 560 | + | func turnErrorMessage(err error) string { | |||
| 561 | + | if provider.IsContextOverflowError(err) { | |||
| 562 | + | return subagentContextLimitMessage | |||
| 563 | + | } | |||
| 564 | + | return errString(err) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 504 | func errString(err error) string { | 567 | func errString(err error) string { | ||
| 505 | if err == nil { | 568 | if err == nil { | ||
| 506 | return "" | 569 | return "" | ||
M packages/agent/subagent_worker_test.go +340 -0
| @@ -2,14 +2,19 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 5 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 6 | 8 | "io" | |
| 7 | 9 | "os" | |
| 8 | 10 | "path/filepath" | |
| 9 | 11 | "strings" | |
| 12 | + | "sync" | |
| 10 | 13 | "testing" | |
| 11 | 14 | ||
| 12 | 15 | "github.com/bnema/zut/packages/agent/subagents" | |
| 16 | + | "github.com/bnema/zut/packages/core" | |
| 17 | + | "github.com/bnema/zut/packages/provider" | |
| 13 | 18 | ) | |
| 14 | 19 | ||
| 15 | 20 | // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads | |
| @@ -30,6 +35,341 @@ func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) { | |||
| 30 | 35 | } | |
| 31 | 36 | } | |
| 32 | 37 | ||
| 38 | + | type subagentContextRecoveryClient struct { | |
| 39 | + | mu sync.Mutex | |
| 40 | + | calls int | |
| 41 | + | requests []provider.Request | |
| 42 | + | retried chan provider.Request | |
| 43 | + | compactionErr error | |
| 44 | + | retryOverflow bool | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (c *subagentContextRecoveryClient) Name() string { return "subagent-context-recovery-test" } | |
| 48 | + | ||
| 49 | + | func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 50 | + | c.mu.Lock() | |
| 51 | + | c.calls++ | |
| 52 | + | call := c.calls | |
| 53 | + | c.requests = append(c.requests, req) | |
| 54 | + | c.mu.Unlock() | |
| 55 | + | ||
| 56 | + | events := make(chan provider.Event, 2) | |
| 57 | + | go func() { | |
| 58 | + | defer close(events) | |
| 59 | + | switch call { | |
| 60 | + | case 1: | |
| 61 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")} | |
| 62 | + | case 2: | |
| 63 | + | if c.compactionErr != nil { | |
| 64 | + | events <- provider.EventDone{Stop: provider.StopError, Err: c.compactionErr} | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | events <- provider.EventTextDelta{Delta: "summary"} | |
| 68 | + | events <- provider.EventDone{Stop: provider.StopEnd} | |
| 69 | + | default: | |
| 70 | + | c.retried <- req | |
| 71 | + | if c.retryOverflow { | |
| 72 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")} | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |
| 76 | + | Role: provider.RoleAssistant, | |
| 77 | + | Content: []provider.Content{provider.TextBlock{Text: "recovered"}}, | |
| 78 | + | }} | |
| 79 | + | } | |
| 80 | + | }() | |
| 81 | + | return events, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) { | |
| 85 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 86 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 87 | + | agent.SetMessages([]provider.Message{ | |
| 88 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |
| 89 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |
| 90 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |
| 91 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |
| 92 | + | }) | |
| 93 | + | ||
| 94 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |
| 95 | + | if err != nil { | |
| 96 | + | t.Fatalf("promptWithContextRecovery returned %v", err) | |
| 97 | + | } | |
| 98 | + | if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" { | |
| 99 | + | t.Fatalf("final assistant text = %q, want recovered", got) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | request := <-client.retried | |
| 103 | + | if got := workerRequestUserTextCount(request, "finish the task"); got != 1 { | |
| 104 | + | t.Fatalf("retried request contains prompt %d times, want 1: %#v", got, request.Messages) | |
| 105 | + | } | |
| 106 | + | client.mu.Lock() | |
| 107 | + | calls := client.calls | |
| 108 | + | requests := append([]provider.Request(nil), client.requests...) | |
| 109 | + | client.mu.Unlock() | |
| 110 | + | if calls != 3 || len(requests) != 3 { | |
| 111 | + | t.Fatalf("provider calls/requests = %d/%d, want 3/3", calls, len(requests)) | |
| 112 | + | } | |
| 113 | + | if !workerRequestContainsText(requests[1], "<conversation>") || !workerRequestContainsText(requests[1], "one") { | |
| 114 | + | t.Fatalf("compaction request does not include the older transcript: %#v", requests[1].Messages) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) { | |
| 119 | + | client := &subagentContextRecoveryClient{ | |
| 120 | + | retried: make(chan provider.Request, 1), | |
| 121 | + | retryOverflow: true, | |
| 122 | + | } | |
| 123 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 124 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 125 | + | ||
| 126 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |
| 127 | + | if !provider.IsContextOverflowError(err) { | |
| 128 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |
| 129 | + | } | |
| 130 | + | <-client.retried | |
| 131 | + | client.mu.Lock() | |
| 132 | + | calls := client.calls | |
| 133 | + | client.mu.Unlock() | |
| 134 | + | if calls != 3 { | |
| 135 | + | t.Fatalf("provider calls = %d, want one initial prompt, compaction, and one continuation", calls) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T) { | |
| 140 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 141 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 142 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 143 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 144 | + | defer cancel() | |
| 145 | + | ||
| 146 | + | _, err := promptWithContextRecovery(ctx, agent, "finish the task", func(event core.AgentEvent) { | |
| 147 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |
| 148 | + | cancel() | |
| 149 | + | } | |
| 150 | + | }, nil) | |
| 151 | + | if !provider.IsContextOverflowError(err) { | |
| 152 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |
| 153 | + | } | |
| 154 | + | if err := ctx.Err(); !errors.Is(err, context.Canceled) { | |
| 155 | + | t.Fatalf("context error = %v, want %v", err, context.Canceled) | |
| 156 | + | } | |
| 157 | + | client.mu.Lock() | |
| 158 | + | calls := client.calls | |
| 159 | + | client.mu.Unlock() | |
| 160 | + | if calls != 1 { | |
| 161 | + | t.Fatalf("provider calls = %d, want only the initial prompt after cancellation", calls) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) { | |
| 166 | + | client := &subagentContextRecoveryClient{ | |
| 167 | + | retried: make(chan provider.Request, 1), | |
| 168 | + | compactionErr: errors.New("compaction provider unavailable"), | |
| 169 | + | } | |
| 170 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 171 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 172 | + | ||
| 173 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |
| 174 | + | if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") { | |
| 175 | + | t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err) | |
| 176 | + | } | |
| 177 | + | payload := resultErrorPayload(err) | |
| 178 | + | if payload["code"] != "turn_failed" { | |
| 179 | + | t.Fatalf("result error payload = %#v, want turn_failed", payload) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) { | |
| 184 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 185 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 186 | + | ||
| 187 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |
| 188 | + | if !provider.IsContextOverflowError(err) { | |
| 189 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |
| 190 | + | } | |
| 191 | + | client.mu.Lock() | |
| 192 | + | calls := client.calls | |
| 193 | + | requests := append([]provider.Request(nil), client.requests...) | |
| 194 | + | client.mu.Unlock() | |
| 195 | + | if len(requests) != 1 || workerRequestUserTextCount(requests[0], "finish the task") != 1 { | |
| 196 | + | t.Fatalf("initial request does not retain the prompt: %#v", requests) | |
| 197 | + | } | |
| 198 | + | if len(agent.Messages()) != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 { | |
| 199 | + | t.Fatalf("single prompt was not retained: %#v", agent.Messages()) | |
| 200 | + | } | |
| 201 | + | if calls != 1 { | |
| 202 | + | t.Fatalf("provider calls = %d, want 1 without compaction", calls) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T) { | |
| 207 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 208 | + | history := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}} | |
| 209 | + | session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |
| 210 | + | if err != nil { | |
| 211 | + | t.Fatalf("create session: %v", err) | |
| 212 | + | } | |
| 213 | + | if err := session.AppendMessage(history[0]); err != nil { | |
| 214 | + | t.Fatalf("append history: %v", err) | |
| 215 | + | } | |
| 216 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 217 | + | agent.SetMessages(history) | |
| 218 | + | ||
| 219 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error { | |
| 220 | + | return errors.New("persist failed") | |
| 221 | + | }) | |
| 222 | + | if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") { | |
| 223 | + | t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err) | |
| 224 | + | } | |
| 225 | + | if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 { | |
| 226 | + | t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages()) | |
| 227 | + | } | |
| 228 | + | WriteNewTranscript(agent, session, outputStart) | |
| 229 | + | if err := session.Close(); err != nil { | |
| 230 | + | t.Fatalf("close session: %v", err) | |
| 231 | + | } | |
| 232 | + | reopened, messages, err := core.OpenSession(session.Path) | |
| 233 | + | if err != nil { | |
| 234 | + | t.Fatalf("reopen session: %v", err) | |
| 235 | + | } | |
| 236 | + | t.Cleanup(func() { | |
| 237 | + | if err := reopened.Close(); err != nil { | |
| 238 | + | t.Errorf("close reopened session: %v", err) | |
| 239 | + | } | |
| 240 | + | }) | |
| 241 | + | if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 { | |
| 242 | + | t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages) | |
| 243 | + | } | |
| 244 | + | client.mu.Lock() | |
| 245 | + | calls := client.calls | |
| 246 | + | client.mu.Unlock() | |
| 247 | + | if calls != 2 { | |
| 248 | + | t.Fatalf("provider calls = %d, want initial prompt and compaction only", calls) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) { | |
| 253 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 254 | + | history := []provider.Message{ | |
| 255 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |
| 256 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |
| 257 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |
| 258 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |
| 259 | + | } | |
| 260 | + | session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |
| 261 | + | if err != nil { | |
| 262 | + | t.Fatalf("create session: %v", err) | |
| 263 | + | } | |
| 264 | + | for _, message := range history { | |
| 265 | + | if err := session.AppendMessage(message); err != nil { | |
| 266 | + | t.Fatalf("append history: %v", err) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 271 | + | agent.SetMessages(history) | |
| 272 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction) | |
| 273 | + | if err != nil { | |
| 274 | + | t.Fatalf("promptWithContextRecovery returned %v", err) | |
| 275 | + | } | |
| 276 | + | WriteNewTranscript(agent, session, outputStart) | |
| 277 | + | if err := session.Close(); err != nil { | |
| 278 | + | t.Fatalf("close session: %v", err) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | records, err := os.ReadFile(session.Path) | |
| 282 | + | if err != nil { | |
| 283 | + | t.Fatalf("read session: %v", err) | |
| 284 | + | } | |
| 285 | + | compactions := 0 | |
| 286 | + | for _, record := range bytes.Split(bytes.TrimSpace(records), []byte("\n")) { | |
| 287 | + | var entry struct { | |
| 288 | + | Type string `json:"type"` | |
| 289 | + | } | |
| 290 | + | if err := json.Unmarshal(record, &entry); err != nil { | |
| 291 | + | t.Fatalf("decode session record: %v", err) | |
| 292 | + | } | |
| 293 | + | if entry.Type == "compaction" { | |
| 294 | + | compactions++ | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | if compactions != 1 { | |
| 298 | + | t.Fatalf("compaction records = %d, want 1", compactions) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | reopened, messages, err := core.OpenSession(session.Path) | |
| 302 | + | if err != nil { | |
| 303 | + | t.Fatalf("reopen session: %v", err) | |
| 304 | + | } | |
| 305 | + | t.Cleanup(func() { | |
| 306 | + | if err := reopened.Close(); err != nil { | |
| 307 | + | t.Errorf("close reopened session: %v", err) | |
| 308 | + | } | |
| 309 | + | }) | |
| 310 | + | if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 { | |
| 311 | + | t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages) | |
| 312 | + | } | |
| 313 | + | if got := finalAssistantText(messages); got != "two\nfour\nrecovered" { | |
| 314 | + | t.Fatalf("persisted assistant text = %q, want prior tail plus recovered result", got) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func workerRequestMessageTextCount(messages []provider.Message, want string) int { | |
| 319 | + | count := 0 | |
| 320 | + | for _, message := range messages { | |
| 321 | + | for _, block := range message.Content { | |
| 322 | + | if text, ok := block.(provider.TextBlock); ok && text.Text == want { | |
| 323 | + | count++ | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | return count | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func workerRequestContainsText(req provider.Request, want string) bool { | |
| 331 | + | for _, message := range req.Messages { | |
| 332 | + | for _, block := range message.Content { | |
| 333 | + | if text, ok := block.(provider.TextBlock); ok && strings.Contains(text.Text, want) { | |
| 334 | + | return true | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | return false | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func workerRequestUserTextCount(req provider.Request, want string) int { | |
| 342 | + | count := 0 | |
| 343 | + | for _, message := range req.Messages { | |
| 344 | + | if message.Role != provider.RoleUser { | |
| 345 | + | continue | |
| 346 | + | } | |
| 347 | + | for _, block := range message.Content { | |
| 348 | + | if text, ok := block.(provider.TextBlock); ok && text.Text == want { | |
| 349 | + | count++ | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | return count | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) { | |
| 357 | + | payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text")) | |
| 358 | + | if payload["code"] != "context_limit" { | |
| 359 | + | t.Fatalf("error code = %q, want context_limit", payload["code"]) | |
| 360 | + | } | |
| 361 | + | if payload["message"] != subagentContextLimitMessage { | |
| 362 | + | t.Fatalf("error message = %q, want %q", payload["message"], subagentContextLimitMessage) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) { | |
| 367 | + | payload := subagentEventData(core.EvTurnEnd{Err: errors.New("provider error: input exceeds the context window; echoed task text")}) | |
| 368 | + | if payload["error"] != subagentContextLimitMessage { | |
| 369 | + | t.Fatalf("event error = %q, want %q", payload["error"], subagentContextLimitMessage) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 33 | 373 | // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the | |
| 34 | 374 | // "everything is doubled after reopening a subagent agent" bug. | |
| 35 | 375 | // | |
| @@ -2,14 +2,19 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "errors" | |||
| 6 | "io" | 8 | "io" | ||
| 7 | "os" | 9 | "os" | ||
| 8 | "path/filepath" | 10 | "path/filepath" | ||
| 9 | "strings" | 11 | "strings" | ||
| 12 | + | "sync" | |||
| 10 | "testing" | 13 | "testing" | ||
| 11 | 14 | ||||
| 12 | "github.com/bnema/zut/packages/agent/subagents" | 15 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 16 | + | "github.com/bnema/zut/packages/core" | |||
| 17 | + | "github.com/bnema/zut/packages/provider" | |||
| 13 | ) | 18 | ) | ||
| 14 | 19 | ||||
| 15 | // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads | 20 | // TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads | ||
| @@ -30,6 +35,341 @@ func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) { | |||||
| 30 | } | 35 | } | ||
| 31 | } | 36 | } | ||
| 32 | 37 | ||||
| 38 | + | type subagentContextRecoveryClient struct { | |||
| 39 | + | mu sync.Mutex | |||
| 40 | + | calls int | |||
| 41 | + | requests []provider.Request | |||
| 42 | + | retried chan provider.Request | |||
| 43 | + | compactionErr error | |||
| 44 | + | retryOverflow bool | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (c *subagentContextRecoveryClient) Name() string { return "subagent-context-recovery-test" } | |||
| 48 | + | ||||
| 49 | + | func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 50 | + | c.mu.Lock() | |||
| 51 | + | c.calls++ | |||
| 52 | + | call := c.calls | |||
| 53 | + | c.requests = append(c.requests, req) | |||
| 54 | + | c.mu.Unlock() | |||
| 55 | + | ||||
| 56 | + | events := make(chan provider.Event, 2) | |||
| 57 | + | go func() { | |||
| 58 | + | defer close(events) | |||
| 59 | + | switch call { | |||
| 60 | + | case 1: | |||
| 61 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")} | |||
| 62 | + | case 2: | |||
| 63 | + | if c.compactionErr != nil { | |||
| 64 | + | events <- provider.EventDone{Stop: provider.StopError, Err: c.compactionErr} | |||
| 65 | + | return | |||
| 66 | + | } | |||
| 67 | + | events <- provider.EventTextDelta{Delta: "summary"} | |||
| 68 | + | events <- provider.EventDone{Stop: provider.StopEnd} | |||
| 69 | + | default: | |||
| 70 | + | c.retried <- req | |||
| 71 | + | if c.retryOverflow { | |||
| 72 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")} | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |||
| 76 | + | Role: provider.RoleAssistant, | |||
| 77 | + | Content: []provider.Content{provider.TextBlock{Text: "recovered"}}, | |||
| 78 | + | }} | |||
| 79 | + | } | |||
| 80 | + | }() | |||
| 81 | + | return events, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) { | |||
| 85 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 86 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 87 | + | agent.SetMessages([]provider.Message{ | |||
| 88 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |||
| 89 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |||
| 90 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |||
| 91 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |||
| 92 | + | }) | |||
| 93 | + | ||||
| 94 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |||
| 95 | + | if err != nil { | |||
| 96 | + | t.Fatalf("promptWithContextRecovery returned %v", err) | |||
| 97 | + | } | |||
| 98 | + | if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" { | |||
| 99 | + | t.Fatalf("final assistant text = %q, want recovered", got) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | request := <-client.retried | |||
| 103 | + | if got := workerRequestUserTextCount(request, "finish the task"); got != 1 { | |||
| 104 | + | t.Fatalf("retried request contains prompt %d times, want 1: %#v", got, request.Messages) | |||
| 105 | + | } | |||
| 106 | + | client.mu.Lock() | |||
| 107 | + | calls := client.calls | |||
| 108 | + | requests := append([]provider.Request(nil), client.requests...) | |||
| 109 | + | client.mu.Unlock() | |||
| 110 | + | if calls != 3 || len(requests) != 3 { | |||
| 111 | + | t.Fatalf("provider calls/requests = %d/%d, want 3/3", calls, len(requests)) | |||
| 112 | + | } | |||
| 113 | + | if !workerRequestContainsText(requests[1], "<conversation>") || !workerRequestContainsText(requests[1], "one") { | |||
| 114 | + | t.Fatalf("compaction request does not include the older transcript: %#v", requests[1].Messages) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) { | |||
| 119 | + | client := &subagentContextRecoveryClient{ | |||
| 120 | + | retried: make(chan provider.Request, 1), | |||
| 121 | + | retryOverflow: true, | |||
| 122 | + | } | |||
| 123 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 124 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 125 | + | ||||
| 126 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |||
| 127 | + | if !provider.IsContextOverflowError(err) { | |||
| 128 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |||
| 129 | + | } | |||
| 130 | + | <-client.retried | |||
| 131 | + | client.mu.Lock() | |||
| 132 | + | calls := client.calls | |||
| 133 | + | client.mu.Unlock() | |||
| 134 | + | if calls != 3 { | |||
| 135 | + | t.Fatalf("provider calls = %d, want one initial prompt, compaction, and one continuation", calls) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T) { | |||
| 140 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 141 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 142 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 143 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 144 | + | defer cancel() | |||
| 145 | + | ||||
| 146 | + | _, err := promptWithContextRecovery(ctx, agent, "finish the task", func(event core.AgentEvent) { | |||
| 147 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |||
| 148 | + | cancel() | |||
| 149 | + | } | |||
| 150 | + | }, nil) | |||
| 151 | + | if !provider.IsContextOverflowError(err) { | |||
| 152 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |||
| 153 | + | } | |||
| 154 | + | if err := ctx.Err(); !errors.Is(err, context.Canceled) { | |||
| 155 | + | t.Fatalf("context error = %v, want %v", err, context.Canceled) | |||
| 156 | + | } | |||
| 157 | + | client.mu.Lock() | |||
| 158 | + | calls := client.calls | |||
| 159 | + | client.mu.Unlock() | |||
| 160 | + | if calls != 1 { | |||
| 161 | + | t.Fatalf("provider calls = %d, want only the initial prompt after cancellation", calls) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) { | |||
| 166 | + | client := &subagentContextRecoveryClient{ | |||
| 167 | + | retried: make(chan provider.Request, 1), | |||
| 168 | + | compactionErr: errors.New("compaction provider unavailable"), | |||
| 169 | + | } | |||
| 170 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 171 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 172 | + | ||||
| 173 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |||
| 174 | + | if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") { | |||
| 175 | + | t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err) | |||
| 176 | + | } | |||
| 177 | + | payload := resultErrorPayload(err) | |||
| 178 | + | if payload["code"] != "turn_failed" { | |||
| 179 | + | t.Fatalf("result error payload = %#v, want turn_failed", payload) | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) { | |||
| 184 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 185 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 186 | + | ||||
| 187 | + | _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil) | |||
| 188 | + | if !provider.IsContextOverflowError(err) { | |||
| 189 | + | t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err) | |||
| 190 | + | } | |||
| 191 | + | client.mu.Lock() | |||
| 192 | + | calls := client.calls | |||
| 193 | + | requests := append([]provider.Request(nil), client.requests...) | |||
| 194 | + | client.mu.Unlock() | |||
| 195 | + | if len(requests) != 1 || workerRequestUserTextCount(requests[0], "finish the task") != 1 { | |||
| 196 | + | t.Fatalf("initial request does not retain the prompt: %#v", requests) | |||
| 197 | + | } | |||
| 198 | + | if len(agent.Messages()) != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 { | |||
| 199 | + | t.Fatalf("single prompt was not retained: %#v", agent.Messages()) | |||
| 200 | + | } | |||
| 201 | + | if calls != 1 { | |||
| 202 | + | t.Fatalf("provider calls = %d, want 1 without compaction", calls) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T) { | |||
| 207 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 208 | + | history := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}} | |||
| 209 | + | session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |||
| 210 | + | if err != nil { | |||
| 211 | + | t.Fatalf("create session: %v", err) | |||
| 212 | + | } | |||
| 213 | + | if err := session.AppendMessage(history[0]); err != nil { | |||
| 214 | + | t.Fatalf("append history: %v", err) | |||
| 215 | + | } | |||
| 216 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 217 | + | agent.SetMessages(history) | |||
| 218 | + | ||||
| 219 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error { | |||
| 220 | + | return errors.New("persist failed") | |||
| 221 | + | }) | |||
| 222 | + | if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") { | |||
| 223 | + | t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err) | |||
| 224 | + | } | |||
| 225 | + | if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 { | |||
| 226 | + | t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages()) | |||
| 227 | + | } | |||
| 228 | + | WriteNewTranscript(agent, session, outputStart) | |||
| 229 | + | if err := session.Close(); err != nil { | |||
| 230 | + | t.Fatalf("close session: %v", err) | |||
| 231 | + | } | |||
| 232 | + | reopened, messages, err := core.OpenSession(session.Path) | |||
| 233 | + | if err != nil { | |||
| 234 | + | t.Fatalf("reopen session: %v", err) | |||
| 235 | + | } | |||
| 236 | + | t.Cleanup(func() { | |||
| 237 | + | if err := reopened.Close(); err != nil { | |||
| 238 | + | t.Errorf("close reopened session: %v", err) | |||
| 239 | + | } | |||
| 240 | + | }) | |||
| 241 | + | if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 { | |||
| 242 | + | t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages) | |||
| 243 | + | } | |||
| 244 | + | client.mu.Lock() | |||
| 245 | + | calls := client.calls | |||
| 246 | + | client.mu.Unlock() | |||
| 247 | + | if calls != 2 { | |||
| 248 | + | t.Fatalf("provider calls = %d, want initial prompt and compaction only", calls) | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) { | |||
| 253 | + | client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 254 | + | history := []provider.Message{ | |||
| 255 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |||
| 256 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |||
| 257 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |||
| 258 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |||
| 259 | + | } | |||
| 260 | + | session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |||
| 261 | + | if err != nil { | |||
| 262 | + | t.Fatalf("create session: %v", err) | |||
| 263 | + | } | |||
| 264 | + | for _, message := range history { | |||
| 265 | + | if err := session.AppendMessage(message); err != nil { | |||
| 266 | + | t.Fatalf("append history: %v", err) | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 271 | + | agent.SetMessages(history) | |||
| 272 | + | outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction) | |||
| 273 | + | if err != nil { | |||
| 274 | + | t.Fatalf("promptWithContextRecovery returned %v", err) | |||
| 275 | + | } | |||
| 276 | + | WriteNewTranscript(agent, session, outputStart) | |||
| 277 | + | if err := session.Close(); err != nil { | |||
| 278 | + | t.Fatalf("close session: %v", err) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | records, err := os.ReadFile(session.Path) | |||
| 282 | + | if err != nil { | |||
| 283 | + | t.Fatalf("read session: %v", err) | |||
| 284 | + | } | |||
| 285 | + | compactions := 0 | |||
| 286 | + | for _, record := range bytes.Split(bytes.TrimSpace(records), []byte("\n")) { | |||
| 287 | + | var entry struct { | |||
| 288 | + | Type string `json:"type"` | |||
| 289 | + | } | |||
| 290 | + | if err := json.Unmarshal(record, &entry); err != nil { | |||
| 291 | + | t.Fatalf("decode session record: %v", err) | |||
| 292 | + | } | |||
| 293 | + | if entry.Type == "compaction" { | |||
| 294 | + | compactions++ | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | if compactions != 1 { | |||
| 298 | + | t.Fatalf("compaction records = %d, want 1", compactions) | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | reopened, messages, err := core.OpenSession(session.Path) | |||
| 302 | + | if err != nil { | |||
| 303 | + | t.Fatalf("reopen session: %v", err) | |||
| 304 | + | } | |||
| 305 | + | t.Cleanup(func() { | |||
| 306 | + | if err := reopened.Close(); err != nil { | |||
| 307 | + | t.Errorf("close reopened session: %v", err) | |||
| 308 | + | } | |||
| 309 | + | }) | |||
| 310 | + | if got := workerRequestMessageTextCount(messages, "finish the task"); got != 1 { | |||
| 311 | + | t.Fatalf("persisted prompt count = %d, want 1: %#v", got, messages) | |||
| 312 | + | } | |||
| 313 | + | if got := finalAssistantText(messages); got != "two\nfour\nrecovered" { | |||
| 314 | + | t.Fatalf("persisted assistant text = %q, want prior tail plus recovered result", got) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func workerRequestMessageTextCount(messages []provider.Message, want string) int { | |||
| 319 | + | count := 0 | |||
| 320 | + | for _, message := range messages { | |||
| 321 | + | for _, block := range message.Content { | |||
| 322 | + | if text, ok := block.(provider.TextBlock); ok && text.Text == want { | |||
| 323 | + | count++ | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | return count | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func workerRequestContainsText(req provider.Request, want string) bool { | |||
| 331 | + | for _, message := range req.Messages { | |||
| 332 | + | for _, block := range message.Content { | |||
| 333 | + | if text, ok := block.(provider.TextBlock); ok && strings.Contains(text.Text, want) { | |||
| 334 | + | return true | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | return false | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func workerRequestUserTextCount(req provider.Request, want string) int { | |||
| 342 | + | count := 0 | |||
| 343 | + | for _, message := range req.Messages { | |||
| 344 | + | if message.Role != provider.RoleUser { | |||
| 345 | + | continue | |||
| 346 | + | } | |||
| 347 | + | for _, block := range message.Content { | |||
| 348 | + | if text, ok := block.(provider.TextBlock); ok && text.Text == want { | |||
| 349 | + | count++ | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | return count | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) { | |||
| 357 | + | payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text")) | |||
| 358 | + | if payload["code"] != "context_limit" { | |||
| 359 | + | t.Fatalf("error code = %q, want context_limit", payload["code"]) | |||
| 360 | + | } | |||
| 361 | + | if payload["message"] != subagentContextLimitMessage { | |||
| 362 | + | t.Fatalf("error message = %q, want %q", payload["message"], subagentContextLimitMessage) | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) { | |||
| 367 | + | payload := subagentEventData(core.EvTurnEnd{Err: errors.New("provider error: input exceeds the context window; echoed task text")}) | |||
| 368 | + | if payload["error"] != subagentContextLimitMessage { | |||
| 369 | + | t.Fatalf("event error = %q, want %q", payload["error"], subagentContextLimitMessage) | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 33 | // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the | 373 | // TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the | ||
| 34 | // "everything is doubled after reopening a subagent agent" bug. | 374 | // "everything is doubled after reopening a subagent agent" bug. | ||
| 35 | // | 375 | // | ||
A packages/provider/context_overflow.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // IsContextOverflowError reports whether err indicates that the provider | |
| 6 | + | // rejected a request because its payload or tokenized input exceeds the | |
| 7 | + | // serving model's context limit. Provider responses use different HTTP status | |
| 8 | + | // codes, error codes, and prose, so callers use this semantic classification | |
| 9 | + | // when deciding whether compaction is a safe recovery attempt. | |
| 10 | + | func IsContextOverflowError(err error) bool { | |
| 11 | + | if err == nil { | |
| 12 | + | return false | |
| 13 | + | } | |
| 14 | + | msg := strings.ToLower(err.Error()) | |
| 15 | + | return hasHTTPStatus(msg, "413") || | |
| 16 | + | strings.Contains(msg, "payload too large") || | |
| 17 | + | strings.Contains(msg, "request entity too large") || | |
| 18 | + | strings.Contains(msg, "input exceeds the context window") || | |
| 19 | + | strings.Contains(msg, "context window exceeded") || | |
| 20 | + | strings.Contains(msg, "maximum context length") || | |
| 21 | + | strings.Contains(msg, "context_length_exceeded") || | |
| 22 | + | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func hasHTTPStatus(message, code string) bool { | |
| 26 | + | fields := strings.Fields(message) | |
| 27 | + | for i, field := range fields { | |
| 28 | + | if strings.Trim(field, "():,;[]{}") != code { | |
| 29 | + | continue | |
| 30 | + | } | |
| 31 | + | if i == 0 { | |
| 32 | + | return true | |
| 33 | + | } | |
| 34 | + | for offset := 1; offset <= 2 && i-offset >= 0; offset++ { | |
| 35 | + | prefix := strings.Trim(fields[i-offset], "():,;[]{}") | |
| 36 | + | if prefix == "http" || prefix == "status" || prefix == "response" { | |
| 37 | + | return true | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | return false | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // IsContextOverflowError reports whether err indicates that the provider | |||
| 6 | + | // rejected a request because its payload or tokenized input exceeds the | |||
| 7 | + | // serving model's context limit. Provider responses use different HTTP status | |||
| 8 | + | // codes, error codes, and prose, so callers use this semantic classification | |||
| 9 | + | // when deciding whether compaction is a safe recovery attempt. | |||
| 10 | + | func IsContextOverflowError(err error) bool { | |||
| 11 | + | if err == nil { | |||
| 12 | + | return false | |||
| 13 | + | } | |||
| 14 | + | msg := strings.ToLower(err.Error()) | |||
| 15 | + | return hasHTTPStatus(msg, "413") || | |||
| 16 | + | strings.Contains(msg, "payload too large") || | |||
| 17 | + | strings.Contains(msg, "request entity too large") || | |||
| 18 | + | strings.Contains(msg, "input exceeds the context window") || | |||
| 19 | + | strings.Contains(msg, "context window exceeded") || | |||
| 20 | + | strings.Contains(msg, "maximum context length") || | |||
| 21 | + | strings.Contains(msg, "context_length_exceeded") || | |||
| 22 | + | (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens")) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func hasHTTPStatus(message, code string) bool { | |||
| 26 | + | fields := strings.Fields(message) | |||
| 27 | + | for i, field := range fields { | |||
| 28 | + | if strings.Trim(field, "():,;[]{}") != code { | |||
| 29 | + | continue | |||
| 30 | + | } | |||
| 31 | + | if i == 0 { | |||
| 32 | + | return true | |||
| 33 | + | } | |||
| 34 | + | for offset := 1; offset <= 2 && i-offset >= 0; offset++ { | |||
| 35 | + | prefix := strings.Trim(fields[i-offset], "():,;[]{}") | |||
| 36 | + | if prefix == "http" || prefix == "status" || prefix == "response" { | |||
| 37 | + | return true | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | return false | |||
| 42 | + | } | |||
A packages/provider/context_overflow_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestIsContextOverflowError(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | err error | |
| 12 | + | want bool | |
| 13 | + | }{ | |
| 14 | + | {name: "nil error", err: nil, want: false}, | |
| 15 | + | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |
| 16 | + | {name: "response 413", err: errors.New("provider response 413: request rejected"), want: true}, | |
| 17 | + | {name: "HTTP status code 413", err: errors.New("provider request failed: HTTP status code: 413"), want: true}, | |
| 18 | + | {name: "payload too large", err: errors.New("proxy rejected request: Payload Too Large"), want: true}, | |
| 19 | + | {name: "request entity too large", err: errors.New("gateway: Request Entity Too Large"), want: true}, | |
| 20 | + | {name: "numeric value is not HTTP 413", err: errors.New("provider http 400: max_tokens cannot exceed 41344"), want: false}, | |
| 21 | + | {name: "unqualified numeric value is not HTTP 413", err: errors.New("max_tokens cannot exceed 413"), want: false}, | |
| 22 | + | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |
| 23 | + | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |
| 24 | + | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |
| 25 | + | {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |
| 26 | + | {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false}, | |
| 27 | + | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | for _, tt := range tests { | |
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |
| 32 | + | if got := IsContextOverflowError(tt.err); got != tt.want { | |
| 33 | + | t.Fatalf("IsContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |
| 34 | + | } | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestIsContextOverflowError(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | err error | |||
| 12 | + | want bool | |||
| 13 | + | }{ | |||
| 14 | + | {name: "nil error", err: nil, want: false}, | |||
| 15 | + | {name: "http 413", err: errors.New("provider http 413: request rejected"), want: true}, | |||
| 16 | + | {name: "response 413", err: errors.New("provider response 413: request rejected"), want: true}, | |||
| 17 | + | {name: "HTTP status code 413", err: errors.New("provider request failed: HTTP status code: 413"), want: true}, | |||
| 18 | + | {name: "payload too large", err: errors.New("proxy rejected request: Payload Too Large"), want: true}, | |||
| 19 | + | {name: "request entity too large", err: errors.New("gateway: Request Entity Too Large"), want: true}, | |||
| 20 | + | {name: "numeric value is not HTTP 413", err: errors.New("provider http 400: max_tokens cannot exceed 41344"), want: false}, | |||
| 21 | + | {name: "unqualified numeric value is not HTTP 413", err: errors.New("max_tokens cannot exceed 413"), want: false}, | |||
| 22 | + | {name: "context error code", err: errors.New("context_length_exceeded"), want: true}, | |||
| 23 | + | {name: "maximum context length", err: errors.New("This model's maximum context length is 128000 tokens"), want: true}, | |||
| 24 | + | {name: "context window exceeded", err: errors.New("context window exceeded"), want: true}, | |||
| 25 | + | {name: "maximum input token count", err: errors.New("input token count exceeds the maximum number of tokens allowed"), want: true}, | |||
| 26 | + | {name: "maximum output token count", err: errors.New("max_tokens exceeds the maximum number of tokens allowed"), want: false}, | |||
| 27 | + | {name: "unrelated error", err: errors.New("rate limit exceeded"), want: false}, | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | for _, tt := range tests { | |||
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 32 | + | if got := IsContextOverflowError(tt.err); got != tt.want { | |||
| 33 | + | t.Fatalf("IsContextOverflowError(%q) = %t, want %t", tt.err, got, tt.want) | |||
| 34 | + | } | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||