|
4 | 4 | "context" |
5 | 5 | "crypto/sha256" |
6 | 6 | "encoding/json" |
| 7 | + "errors" |
7 | 8 | "fmt" |
8 | 9 | "log/slog" |
9 | 10 | "os" |
@@ -1776,6 +1777,50 @@ func (a *Agent) flushLeftoverSteer(sess *session.Session) { |
1776 | 1777 | } |
1777 | 1778 | } |
1778 | 1779 |
|
| 1780 | +// llmRetry wraps an LLM call with retry logic for transient errors (network |
| 1781 | +// glitches, server 5xx, EOF). Context cancellation / deadline exceeded are |
| 1782 | +// treated as terminal — there's no point retrying when the caller has gone |
| 1783 | +// away or the deadline has passed. Uses exponential backoff (1s, 4s, 9s) |
| 1784 | +// across up to wechatLLMRetryAttempts calls. |
| 1785 | +// |
| 1786 | +// The label argument is used for structured logging (typically a.name). |
| 1787 | +const llmRetryAttempts = 3 |
| 1788 | + |
| 1789 | +func llmRetry(ctx context.Context, label string, fn func(context.Context) (*provider.Response, error)) (*provider.Response, error) { |
| 1790 | + var lastErr error |
| 1791 | + for attempt := 1; attempt <= llmRetryAttempts; attempt++ { |
| 1792 | + resp, err := fn(ctx) |
| 1793 | + if err == nil { |
| 1794 | + if attempt > 1 { |
| 1795 | + slog.Info("LLM call succeeded after retries", |
| 1796 | + "agent", label, "attempts", attempt) |
| 1797 | + } |
| 1798 | + return resp, nil |
| 1799 | + } |
| 1800 | + lastErr = err |
| 1801 | + |
| 1802 | + // Context errors are terminal — don't retry. |
| 1803 | + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { |
| 1804 | + return nil, err |
| 1805 | + } |
| 1806 | + |
| 1807 | + if attempt < llmRetryAttempts { |
| 1808 | + backoff := time.Duration(attempt*attempt) * time.Second // 1s, 4s, 9s |
| 1809 | + slog.Warn("LLM call failed, retrying", |
| 1810 | + "agent", label, "attempt", attempt, |
| 1811 | + "max", llmRetryAttempts, "backoff", backoff, "error", err) |
| 1812 | + select { |
| 1813 | + case <-time.After(backoff): |
| 1814 | + case <-ctx.Done(): |
| 1815 | + return nil, errors.Join(lastErr, ctx.Err()) |
| 1816 | + } |
| 1817 | + } |
| 1818 | + } |
| 1819 | + slog.Error("LLM call failed after all retries", |
| 1820 | + "agent", label, "attempts", llmRetryAttempts, "error", lastErr) |
| 1821 | + return nil, lastErr |
| 1822 | +} |
| 1823 | + |
1779 | 1824 | // HandleMessage processes an inbound message through the ReAct loop. |
1780 | 1825 | func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) string { |
1781 | 1826 | // Check for slash commands first. Empty reply means "handled but |
@@ -2039,14 +2084,16 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin |
2039 | 2084 | }) |
2040 | 2085 | } |
2041 | 2086 | dumpLLMRequest(a.name, a.model, llmMessages, callTools) |
2042 | | - resp, err := a.streamChatToResponse(ctx, llmMessages, callTools) |
| 2087 | + resp, err := llmRetry(ctx, a.name, func(ctx context.Context) (*provider.Response, error) { |
| 2088 | + return a.streamChatToResponse(ctx, llmMessages, callTools) |
| 2089 | + }) |
2043 | 2090 |
|
2044 | 2091 | // Hook: AfterModelCall |
2045 | 2092 | hcAfter := &HookContext{AgentName: a.name, Point: AfterModelCall, Messages: messages, Response: resp, Error: err, StartTime: hcBefore.StartTime, Channel: msg.Channel, AccountID: msg.AccountID, ChatID: msg.ChatID, UserID: a.ownerUserID, GoalSessionKey: a.registry.GoalSessionKey()} |
2046 | 2093 | a.hooks.Run(ctx, hcAfter) |
2047 | 2094 |
|
2048 | 2095 | if err != nil { |
2049 | | - slog.Error("LLM chat failed", "agent", a.name, "error", err) |
| 2096 | + slog.Error("LLM chat failed after retries", "agent", a.name, "error", err) |
2050 | 2097 | emitEvent(ctx, ChatEvent{Type: "error", Data: map[string]any{"message": err.Error()}}) |
2051 | 2098 | emitEvent(ctx, ChatEvent{Type: "done"}) |
2052 | 2099 | return "Sorry, I encountered an error processing your request." |
@@ -2693,13 +2740,15 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage) |
2693 | 2740 | a.hooks.Run(ctx, hcBefore) |
2694 | 2741 |
|
2695 | 2742 | dumpLLMRequest(a.name, a.model, messages, toolDefs) |
2696 | | - resp, err := a.provider.Chat(ctx, messages, toolDefs, a.model, a.maxTokens, a.temperature) |
| 2743 | + resp, err := llmRetry(ctx, a.name, func(ctx context.Context) (*provider.Response, error) { |
| 2744 | + return a.provider.Chat(ctx, messages, toolDefs, a.model, a.maxTokens, a.temperature) |
| 2745 | + }) |
2697 | 2746 |
|
2698 | 2747 | hcAfter := &HookContext{AgentName: a.name, Point: AfterModelCall, Messages: messages, Response: resp, Error: err, StartTime: hcBefore.StartTime, Channel: msg.Channel, AccountID: msg.AccountID, ChatID: msg.ChatID, UserID: a.ownerUserID, GoalSessionKey: a.registry.GoalSessionKey()} |
2699 | 2748 | a.hooks.Run(ctx, hcAfter) |
2700 | 2749 |
|
2701 | 2750 | if err != nil { |
2702 | | - slog.Error("LLM chat failed", "agent", a.name, "error", err) |
| 2751 | + slog.Error("LLM chat failed after retries", "agent", a.name, "error", err) |
2703 | 2752 | return a.stringStream("Sorry, I encountered an error processing your request.") |
2704 | 2753 | } |
2705 | 2754 | a.meterTokens(ctx, sess.Key(), resp.Usage, 0) |
|
0 commit comments