diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 5b1fc5e46..84a99d7f0 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -428,6 +428,14 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 4. Run LLM iteration loop finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) if err != nil { + // Show the error to user via channel before returning + if !constants.IsInternalChannel(opts.Channel) { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: fmt.Sprintf("⚠️ Error processing message: %s", err.Error()), + }) + } return "", err } diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index ac556e860..9962621bf 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -169,15 +169,15 @@ func (fc *FallbackChain) Execute( failErr := ClassifyError(err, candidate.Provider, candidate.Model) if failErr == nil { - // Unclassifiable error: do not fallback, return immediately. - result.Attempts = append(result.Attempts, FallbackAttempt{ + // Unclassifiable error: treat as retriable with "unknown" reason. + // This allows fallback to next candidate instead of aborting. + // Examples: connection reset, DNS failures, unexpected API responses. + failErr = &FailoverError{ + Reason: FailoverUnknown, Provider: candidate.Provider, Model: candidate.Model, - Error: err, - Duration: elapsed, - }) - return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", - candidate.Provider, candidate.Model, err) + Wrapped: err, + } } // Non-retriable error: abort immediately. diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index 1970ed49e..d924a147e 100644 --- a/pkg/providers/fallback_test.go +++ b/pkg/providers/fallback_test.go @@ -259,18 +259,56 @@ func TestFallback_UnclassifiedError(t *testing.T) { makeCandidate("anthropic", "claude"), } - attempt := 0 run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { - attempt++ return nil, errors.New("completely unknown internal error") } _, err := fc.Execute(context.Background(), candidates, run) if err == nil { - t.Fatal("expected error for unclassified error") + t.Fatal("expected error when all candidates fail with unclassified error") } - if attempt != 1 { - t.Errorf("attempt = %d, want 1 (should not fallback on unclassified)", attempt) + // Unclassified errors should now be treated as retriable (FailoverUnknown), + // so both candidates should be tried before exhaustion. + var exhausted *FallbackExhaustedError + if !errors.As(err, &exhausted) { + t.Fatalf("expected FallbackExhaustedError, got %T: %v", err, err) + } + if len(exhausted.Attempts) != 2 { + t.Errorf("attempts = %d, want 2 (both candidates should be tried)", len(exhausted.Attempts)) + } +} + +func TestFallback_UnclassifiedError_FallbackSucceeds(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{ + makeCandidate("sumo", "seed-mini"), + makeCandidate("groq", "llama-3"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + if attempt == 1 { + // Simulate unclassified error (e.g. connection reset) + return nil, errors.New("read tcp: connection reset by peer") + } + return &LLMResponse{Content: "fallback response", FinishReason: "stop"}, nil + } + + result, err := fc.Execute(context.Background(), candidates, run) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Provider != "groq" { + t.Errorf("provider = %q, want groq (fallback)", result.Provider) + } + if result.Response.Content != "fallback response" { + t.Errorf("content = %q, want 'fallback response'", result.Response.Content) + } + if len(result.Attempts) != 1 { + t.Errorf("attempts = %d, want 1 (failed first candidate recorded)", len(result.Attempts)) } }