diff --git a/pkg/agent/loop_execute_llm.go b/pkg/agent/loop_execute_llm.go index 50e10d082..fd4ec75f4 100644 --- a/pkg/agent/loop_execute_llm.go +++ b/pkg/agent/loop_execute_llm.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "jane/pkg/bus" @@ -77,25 +76,27 @@ func (al *AgentLoop) executeLLMWithRetry( break } - errMsg := strings.ToLower(err.Error()) + isTimeoutError := false + isContextError := false - // Check if this is a network/HTTP timeout — not a context window error. - isTimeoutError := errors.Is(err, context.DeadlineExceeded) || - strings.Contains(errMsg, "deadline exceeded") || - strings.Contains(errMsg, "client.timeout") || - strings.Contains(errMsg, "timed out") || - strings.Contains(errMsg, "timeout exceeded") - - // Detect real context window / token limit errors, excluding network timeouts. - isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || - strings.Contains(errMsg, "context window") || - strings.Contains(errMsg, "maximum context length") || - strings.Contains(errMsg, "token limit") || - strings.Contains(errMsg, "too many tokens") || - strings.Contains(errMsg, "max_tokens") || - strings.Contains(errMsg, "invalidparameter") || - strings.Contains(errMsg, "prompt is too long") || - strings.Contains(errMsg, "request too large")) + var failErr *providers.FailoverError + if errors.As(err, &failErr) { + if failErr.Reason == providers.FailoverTimeout { + isTimeoutError = true + } else if failErr.Reason == providers.FailoverContextLength { + isContextError = true + } + } else { + // If not a fallback error, check directly using ClassifyError + // The provider might not be wrapped if no fallback chain is active + if directFailErr := providers.ClassifyError(err, "", ""); directFailErr != nil { + if directFailErr.Reason == providers.FailoverTimeout { + isTimeoutError = true + } else if directFailErr.Reason == providers.FailoverContextLength { + isContextError = true + } + } + } if isTimeoutError && retry < maxRetries { // Exponential backoff: 2s, 4s, 8s diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index fd9bf1e81..c3901d52c 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -85,6 +85,18 @@ var ( substr("invalid request format"), } + contextLengthPatterns = []errorPattern{ + substr("context_length_exceeded"), + substr("context window"), + substr("maximum context length"), + substr("token limit"), + substr("too many tokens"), + substr("max_tokens"), + substr("invalidparameter"), + substr("prompt is too long"), + substr("request too large"), + } + imageDimensionPatterns = []errorPattern{ rxp(`image dimensions exceed max`), } @@ -201,6 +213,9 @@ func classifyByMessage(msg string) FailoverReason { if matchesAny(msg, formatPatterns) { return FailoverFormat } + if matchesAny(msg, contextLengthPatterns) { + return FailoverContextLength + } return "" } diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go index 67d9af62b..5826dfef0 100644 --- a/pkg/providers/error_classifier_test.go +++ b/pkg/providers/error_classifier_test.go @@ -207,6 +207,32 @@ func TestClassifyError_FormatPatterns(t *testing.T) { } } +func TestClassifyError_ContextLengthPatterns(t *testing.T) { + patterns := []string{ + "context_length_exceeded", + "context window reached", + "maximum context length exceeded", + "token limit reached", + "too many tokens for this model", + "max_tokens limit hit", + "invalidparameter", + "prompt is too long", + "request too large", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "anthropic", "claude") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverContextLength { + t.Errorf("pattern %q: reason = %q, want context_length", msg, result.Reason) + } + } +} + func TestClassifyError_ImageDimensionError(t *testing.T) { err := errors.New("image dimensions exceed max allowed 2048x2048") result := ClassifyError(err, "openai", "gpt-4o") @@ -265,6 +291,7 @@ func TestFailoverError_IsRetriable(t *testing.T) { {FailoverTimeout, true}, {FailoverOverloaded, true}, {FailoverFormat, false}, + {FailoverContextLength, false}, {FailoverUnknown, true}, } diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 11114f3df..da0a6317f 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -48,13 +48,14 @@ type ThinkingCapable interface { type FailoverReason string const ( - FailoverAuth FailoverReason = "auth" - FailoverRateLimit FailoverReason = "rate_limit" - FailoverBilling FailoverReason = "billing" - FailoverTimeout FailoverReason = "timeout" - FailoverFormat FailoverReason = "format" - FailoverOverloaded FailoverReason = "overloaded" - FailoverUnknown FailoverReason = "unknown" + FailoverAuth FailoverReason = "auth" + FailoverRateLimit FailoverReason = "rate_limit" + FailoverBilling FailoverReason = "billing" + FailoverTimeout FailoverReason = "timeout" + FailoverFormat FailoverReason = "format" + FailoverContextLength FailoverReason = "context_length" + FailoverOverloaded FailoverReason = "overloaded" + FailoverUnknown FailoverReason = "unknown" ) // FailoverError wraps an LLM provider error with classification metadata. @@ -76,9 +77,9 @@ func (e *FailoverError) Unwrap() error { } // IsRetriable returns true if this error should trigger fallback to next candidate. -// Non-retriable: Format errors (bad request structure, image dimension/size). +// Non-retriable: Format errors (bad request structure, image dimension/size), Context length exceeded. func (e *FailoverError) IsRetriable() bool { - return e.Reason != FailoverFormat + return e.Reason != FailoverFormat && e.Reason != FailoverContextLength } // ModelConfig holds primary model and fallback list.