refactor: decompose AgentLoop and improve code quality

- Extract ContextCompressor (compression, summarization, token estimation) from AgentLoop

- Extract ToolExecutor (tool dispatch, result routing, context updates) from AgentLoop

- Create named constants replacing magic numbers (retry limits, thresholds, timeouts)

- Replace fragile string-matching error detection with structured IsContextWindowError()

- Add contextWindowPatterns and FailoverContextWindow to error classifier

- Refactor provider factory to table-driven registry approach

- Add graceful shutdown with context cancellation and WaitGroup

- Net reduction of ~143 lines from loop.go via component extraction
This commit is contained in:
Sai Balusu 2026-02-23 20:43:14 -05:00
parent 7cbfa89a96
commit 05f80c5f3d
7 changed files with 752 additions and 504 deletions

43
pkg/agent/constants.go Normal file
View file

@ -0,0 +1,43 @@
package agent
import "time"
// Agent loop constants — extracted from inline magic numbers for maintainability.
const (
// DefaultMaxLLMRetries is the maximum number of retries for LLM calls
// when a context window error is detected.
DefaultMaxLLMRetries = 2
// SummarizeMessageThreshold is the minimum number of messages in session
// history before summarization is triggered.
SummarizeMessageThreshold = 20
// ContextWindowUsagePercent is the percentage of the context window that,
// when exceeded by the token estimate, triggers summarization.
ContextWindowUsagePercent = 75
// MessagesKeptAfterSummary is the number of most recent messages kept
// after summarization to maintain conversational continuity.
MessagesKeptAfterSummary = 4
// SummarizationTimeout is the maximum time allowed for the summarization
// LLM call(s) before they are cancelled.
SummarizationTimeout = 120 * time.Second
// SummarizeMaxTokens is the max tokens requested from the LLM for
// summarization responses.
SummarizeMaxTokens = 1024
// SummarizeTemperature is the temperature used for summarization calls
// (low for deterministic output).
SummarizeTemperature = 0.3
// MinHistoryForCompression is the minimum number of messages in history
// before force compression will attempt to reduce it.
MinHistoryForCompression = 4
// MultiPartSummarizationThreshold is the number of messages above which
// summarization splits messages into two batches for separate summarization
// before merging.
MultiPartSummarizationThreshold = 10
)

View file

@ -0,0 +1,237 @@
package agent
import (
"context"
"fmt"
"strings"
"sync"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
// ContextCompressor handles context window management: force compression,
// summarization triggers, and session history optimization.
// Extracted from AgentLoop to improve separation of concerns.
type ContextCompressor struct {
bus *bus.MessageBus
summarizing *sync.Map
}
// NewContextCompressor creates a new ContextCompressor.
func NewContextCompressor(msgBus *bus.MessageBus, summarizing *sync.Map) *ContextCompressor {
return &ContextCompressor{
bus: msgBus,
summarizing: summarizing,
}
}
// MaybeSummarize triggers summarization if the session history exceeds thresholds.
func (cc *ContextCompressor) MaybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := cc.EstimateTokens(newHistory)
threshold := agent.ContextWindow * ContextWindowUsagePercent / 100
if len(newHistory) > SummarizeMessageThreshold || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey
if _, loading := cc.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
defer cc.summarizing.Delete(summarizeKey)
if !constants.IsInternalChannel(channel) {
cc.bus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: "Memory threshold reached. Optimizing conversation history...",
})
}
cc.SummarizeSession(agent, sessionKey)
}()
}
}
}
// ForceCompression aggressively reduces context when the limit is hit.
// It drops the oldest 50% of messages (keeping system prompt and last user message).
func (cc *ContextCompressor) ForceCompression(agent *AgentInstance, sessionKey string) {
history := agent.Sessions.GetHistory(sessionKey)
if len(history) <= MinHistoryForCompression {
return
}
// Keep system prompt (usually [0]) and the very last message (user's trigger)
// We want to drop the oldest half of the *conversation*
// Assuming [0] is system, [1:] is conversation
conversation := history[1 : len(history)-1]
if len(conversation) == 0 {
return
}
// Helper to find the mid-point of the conversation
mid := len(conversation) / 2
// New history structure:
// 1. System Prompt (with compression note appended)
// 2. Second half of conversation
// 3. Last message
droppedCount := mid
keptConversation := conversation[mid:]
newHistory := make([]providers.Message, 0)
// Append compression note to the original system prompt instead of adding a new system message
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
compressionNote := fmt.Sprintf(
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
droppedCount,
)
enhancedSystemPrompt := history[0]
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
newHistory = append(newHistory, enhancedSystemPrompt)
newHistory = append(newHistory, keptConversation...)
newHistory = append(newHistory, history[len(history)-1]) // Last message
// Update session
agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey)
logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
})
}
// SummarizeSession summarizes the conversation history for a session.
func (cc *ContextCompressor) SummarizeSession(agent *AgentInstance, sessionKey string) {
ctx, cancel := context.WithTimeout(context.Background(), SummarizationTimeout)
defer cancel()
history := agent.Sessions.GetHistory(sessionKey)
summary := agent.Sessions.GetSummary(sessionKey)
// Keep last N messages for continuity
if len(history) <= MessagesKeptAfterSummary {
return
}
toSummarize := history[:len(history)-MessagesKeptAfterSummary]
// Oversized Message Guard
maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
for _, m := range toSummarize {
if m.Role != "user" && m.Role != "assistant" {
continue
}
// Use character-based estimation (2.5 chars per token = totalChars * 2 / 5)
msgTokens := utf8.RuneCountInString(m.Content) * 2 / 5
if msgTokens > maxMessageTokens {
omitted = true
continue
}
validMessages = append(validMessages, m)
}
if len(validMessages) == 0 {
return
}
// Multi-Part Summarization
var finalSummary string
if len(validMessages) > MultiPartSummarizationThreshold {
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
s1, _ := cc.SummarizeBatch(ctx, agent, part1, "")
s2, _ := cc.SummarizeBatch(ctx, agent, part2, "")
mergePrompt := fmt.Sprintf(
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
s1,
s2,
)
resp, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: mergePrompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": SummarizeMaxTokens,
"temperature": SummarizeTemperature,
},
)
if err == nil {
finalSummary = resp.Content
} else {
finalSummary = s1 + " " + s2
}
} else {
finalSummary, _ = cc.SummarizeBatch(ctx, agent, validMessages, summary)
}
if omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
}
if finalSummary != "" {
agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, MessagesKeptAfterSummary)
agent.Sessions.Save(sessionKey)
}
}
// SummarizeBatch summarizes a batch of messages.
func (cc *ContextCompressor) SummarizeBatch(
ctx context.Context,
agent *AgentInstance,
batch []providers.Message,
existingSummary string,
) (string, error) {
var sb strings.Builder
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
if existingSummary != "" {
sb.WriteString("Existing context: ")
sb.WriteString(existingSummary)
sb.WriteString("\n")
}
sb.WriteString("\nCONVERSATION:\n")
for _, m := range batch {
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
}
prompt := sb.String()
response, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: prompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": SummarizeMaxTokens,
"temperature": SummarizeTemperature,
},
)
if err != nil {
return "", err
}
return response.Content, nil
}
// EstimateTokens estimates the number of tokens in a message list.
// Uses a safe heuristic of 2.5 characters per token to account for CJK and other
// overheads better than the previous 3 chars/token.
func (cc *ContextCompressor) EstimateTokens(messages []providers.Message) int {
totalChars := 0
for _, m := range messages {
totalChars += utf8.RuneCountInString(m.Content)
}
// 2.5 chars per token = totalChars * 2 / 5
return totalChars * 2 / 5
}

View file

@ -14,7 +14,6 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
@ -38,6 +37,15 @@ type AgentLoop struct {
summarizing sync.Map summarizing sync.Map
fallback *providers.FallbackChain fallback *providers.FallbackChain
channelManager *channels.Manager channelManager *channels.Manager
// Extracted components for better separation of concerns
compressor *ContextCompressor
toolExec *ToolExecutor
// Graceful shutdown support
cancelCtx context.Context
cancelFunc context.CancelFunc
wg sync.WaitGroup
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
@ -69,13 +77,24 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
stateManager = state.NewManager(defaultAgent.Workspace) stateManager = state.NewManager(defaultAgent.Workspace)
} }
// Initialize extracted components
summarizing := &sync.Map{}
compressor := NewContextCompressor(msgBus, summarizing)
toolExec := NewToolExecutor(msgBus)
// Create cancellation context for graceful shutdown
cancelCtx, cancelFunc := context.WithCancel(context.Background())
return &AgentLoop{ return &AgentLoop{
bus: msgBus, bus: msgBus,
cfg: cfg, cfg: cfg,
registry: registry, registry: registry,
state: stateManager, state: stateManager,
summarizing: sync.Map{},
fallback: fallbackChain, fallback: fallbackChain,
compressor: compressor,
toolExec: toolExec,
cancelCtx: cancelCtx,
cancelFunc: cancelFunc,
} }
} }
@ -202,6 +221,10 @@ func (al *AgentLoop) Run(ctx context.Context) error {
func (al *AgentLoop) Stop() { func (al *AgentLoop) Stop() {
al.running.Store(false) al.running.Store(false)
if al.cancelFunc != nil {
al.cancelFunc()
}
al.wg.Wait()
} }
func (al *AgentLoop) RegisterTool(tool tools.Tool) { func (al *AgentLoop) RegisterTool(tool tools.Tool) {
@ -404,7 +427,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
} }
// 1. Update tool contexts // 1. Update tool contexts
al.updateToolContexts(agent, opts.Channel, opts.ChatID) al.toolExec.UpdateToolContexts(agent, opts.Channel, opts.ChatID)
// 2. Build messages (skip history for heartbeat) // 2. Build messages (skip history for heartbeat)
var history []providers.Message var history []providers.Message
@ -445,7 +468,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 7. Optional: summarization // 7. Optional: summarization
if opts.EnableSummary { if opts.EnableSummary {
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID) al.compressor.MaybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
} }
// 8. Optional: send response via bus // 8. Optional: send response via bus
@ -545,18 +568,14 @@ func (al *AgentLoop) runLLMIteration(
} }
// Retry loop for context/token errors // Retry loop for context/token errors
maxRetries := 2 maxRetries := DefaultMaxLLMRetries
for retry := 0; retry <= maxRetries; retry++ { for retry := 0; retry <= maxRetries; retry++ {
response, err = callLLM() response, err = callLLM()
if err == nil { if err == nil {
break break
} }
errMsg := strings.ToLower(err.Error()) isContextError := providers.IsContextWindowError(err)
isContextError := strings.Contains(errMsg, "token") ||
strings.Contains(errMsg, "context") ||
strings.Contains(errMsg, "invalidparameter") ||
strings.Contains(errMsg, "length")
if isContextError && retry < maxRetries { if isContextError && retry < maxRetries {
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
@ -572,7 +591,7 @@ func (al *AgentLoop) runLLMIteration(
}) })
} }
al.forceCompression(agent, opts.SessionKey) al.compressor.ForceCompression(agent, opts.SessionKey)
newHistory := agent.Sessions.GetHistory(opts.SessionKey) newHistory := agent.Sessions.GetHistory(opts.SessionKey)
newSummary := agent.Sessions.GetSummary(opts.SessionKey) newSummary := agent.Sessions.GetSummary(opts.SessionKey)
messages = agent.ContextBuilder.BuildMessages( messages = agent.ContextBuilder.BuildMessages(
@ -656,174 +675,17 @@ func (al *AgentLoop) runLLMIteration(
// Save assistant message with tool calls to session // Save assistant message with tool calls to session
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Execute tool calls // Execute tool calls via the ToolExecutor component
for _, tc := range normalizedToolCalls { toolResultMsgs := al.toolExec.ExecuteToolCalls(ctx, agent, normalizedToolCalls, opts)
argsJSON, _ := json.Marshal(tc.Arguments) for _, resultMsg := range toolResultMsgs {
argsPreview := utils.Truncate(string(argsJSON), 200) messages = append(messages, resultMsg)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), agent.Sessions.AddFullMessage(opts.SessionKey, resultMsg)
map[string]any{
"agent_id": agent.ID,
"tool": tc.Name,
"iteration": iteration,
})
// Create async callback for tools that implement AsyncTool
// NOTE: Following openclaw's design, async tools do NOT send results directly to users.
// Instead, they notify the agent via PublishInbound, and the agent decides
// whether to forward the result to the user (in processSystemMessage).
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
// Log the async completion but don't send directly to user
// The agent will handle user notification via processSystemMessage
if !result.Silent && result.ForUser != "" {
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
map[string]any{
"tool": tc.Name,
"content_len": len(result.ForUser),
})
}
}
toolResult := agent.Tools.ExecuteWithContext(
ctx,
tc.Name,
tc.Arguments,
opts.Channel,
opts.ChatID,
asyncCallback,
)
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: toolResult.ForUser,
})
logger.DebugCF("agent", "Sent tool result to user",
map[string]any{
"tool": tc.Name,
"content_len": len(toolResult.ForUser),
})
}
// Determine content for LLM based on tool result
contentForLLM := toolResult.ForLLM
if contentForLLM == "" && toolResult.Err != nil {
contentForLLM = toolResult.Err.Error()
}
toolResultMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
// Save tool result message to session
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
} }
} }
return finalContent, iteration, nil return finalContent, iteration, nil
} }
// updateToolContexts updates the context for tools that need channel/chatID info.
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
// Use ContextualTool interface instead of type assertions
if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(channel, chatID)
}
}
if tool, ok := agent.Tools.Get("spawn"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
}
if tool, ok := agent.Tools.Get("subagent"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
}
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory)
threshold := agent.ContextWindow * 75 / 100
if len(newHistory) > 20 || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
defer al.summarizing.Delete(summarizeKey)
if !constants.IsInternalChannel(channel) {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: "Memory threshold reached. Optimizing conversation history...",
})
}
al.summarizeSession(agent, sessionKey)
}()
}
}
}
// forceCompression aggressively reduces context when the limit is hit.
// It drops the oldest 50% of messages (keeping system prompt and last user message).
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
history := agent.Sessions.GetHistory(sessionKey)
if len(history) <= 4 {
return
}
// Keep system prompt (usually [0]) and the very last message (user's trigger)
// We want to drop the oldest half of the *conversation*
// Assuming [0] is system, [1:] is conversation
conversation := history[1 : len(history)-1]
if len(conversation) == 0 {
return
}
// Helper to find the mid-point of the conversation
mid := len(conversation) / 2
// New history structure:
// 1. System Prompt (with compression note appended)
// 2. Second half of conversation
// 3. Last message
droppedCount := mid
keptConversation := conversation[mid:]
newHistory := make([]providers.Message, 0)
// Append compression note to the original system prompt instead of adding a new system message
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
compressionNote := fmt.Sprintf(
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
droppedCount,
)
enhancedSystemPrompt := history[0]
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
newHistory = append(newHistory, enhancedSystemPrompt)
newHistory = append(newHistory, keptConversation...)
newHistory = append(newHistory, history[len(history)-1]) // Last message
// Update session
agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey)
logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
})
}
// GetStartupInfo returns information about loaded tools and skills for logging. // GetStartupInfo returns information about loaded tools and skills for logging.
func (al *AgentLoop) GetStartupInfo() map[string]any { func (al *AgentLoop) GetStartupInfo() map[string]any {
info := make(map[string]any) info := make(map[string]any)
@ -903,135 +765,6 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
return sb.String() return sb.String()
} }
// summarizeSession summarizes the conversation history for a session.
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
history := agent.Sessions.GetHistory(sessionKey)
summary := agent.Sessions.GetSummary(sessionKey)
// Keep last 4 messages for continuity
if len(history) <= 4 {
return
}
toSummarize := history[:len(history)-4]
// Oversized Message Guard
maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
for _, m := range toSummarize {
if m.Role != "user" && m.Role != "assistant" {
continue
}
msgTokens := len(m.Content) / 2
if msgTokens > maxMessageTokens {
omitted = true
continue
}
validMessages = append(validMessages, m)
}
if len(validMessages) == 0 {
return
}
// Multi-Part Summarization
var finalSummary string
if len(validMessages) > 10 {
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
mergePrompt := fmt.Sprintf(
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
s1,
s2,
)
resp, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: mergePrompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
},
)
if err == nil {
finalSummary = resp.Content
} else {
finalSummary = s1 + " " + s2
}
} else {
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
}
if omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
}
if finalSummary != "" {
agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, 4)
agent.Sessions.Save(sessionKey)
}
}
// summarizeBatch summarizes a batch of messages.
func (al *AgentLoop) summarizeBatch(
ctx context.Context,
agent *AgentInstance,
batch []providers.Message,
existingSummary string,
) (string, error) {
var sb strings.Builder
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
if existingSummary != "" {
sb.WriteString("Existing context: ")
sb.WriteString(existingSummary)
sb.WriteString("\n")
}
sb.WriteString("\nCONVERSATION:\n")
for _, m := range batch {
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
}
prompt := sb.String()
response, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: prompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
},
)
if err != nil {
return "", err
}
return response.Content, nil
}
// estimateTokens estimates the number of tokens in a message list.
// Uses a safe heuristic of 2.5 characters per token to account for CJK and other
// overheads better than the previous 3 chars/token.
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
totalChars := 0
for _, m := range messages {
totalChars += utf8.RuneCountInString(m.Content)
}
// 2.5 chars per token = totalChars * 2 / 5
return totalChars * 2 / 5
}
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
content := strings.TrimSpace(msg.Content) content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/") { if !strings.HasPrefix(content, "/") {

111
pkg/agent/tool_executor.go Normal file
View file

@ -0,0 +1,111 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
// ToolExecutor handles executing tool calls requested by the LLM,
// including async callback setup, result routing, and context updates.
// Extracted from AgentLoop to improve separation of concerns.
type ToolExecutor struct {
bus *bus.MessageBus
}
// NewToolExecutor creates a new ToolExecutor.
func NewToolExecutor(msgBus *bus.MessageBus) *ToolExecutor {
return &ToolExecutor{bus: msgBus}
}
// ExecuteToolCalls processes a list of normalized tool calls, executes each one,
// sends user-facing results to the bus, and returns the tool result messages
// to be appended to the conversation.
func (te *ToolExecutor) ExecuteToolCalls(
ctx context.Context,
agent *AgentInstance,
toolCalls []providers.ToolCall,
opts processOptions,
) []providers.Message {
var resultMessages []providers.Message
for _, tc := range toolCalls {
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
map[string]any{
"agent_id": agent.ID,
"tool": tc.Name,
})
// Create async callback for tools that implement AsyncTool
// NOTE: Following openclaw's design, async tools do NOT send results directly to users.
// Instead, they notify the agent via PublishInbound, and the agent decides
// whether to forward the result to the user (in processSystemMessage).
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
if !result.Silent && result.ForUser != "" {
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
map[string]any{
"tool": tc.Name,
"content_len": len(result.ForUser),
})
}
}
toolResult := agent.Tools.ExecuteWithContext(
ctx,
tc.Name,
tc.Arguments,
opts.Channel,
opts.ChatID,
asyncCallback,
)
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
te.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: toolResult.ForUser,
})
logger.DebugCF("agent", "Sent tool result to user",
map[string]any{
"tool": tc.Name,
"content_len": len(toolResult.ForUser),
})
}
// Determine content for LLM based on tool result
contentForLLM := toolResult.ForLLM
if contentForLLM == "" && toolResult.Err != nil {
contentForLLM = toolResult.Err.Error()
}
resultMessages = append(resultMessages, providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: tc.ID,
})
}
return resultMessages
}
// UpdateToolContexts updates the context for tools that need channel/chatID info.
func (te *ToolExecutor) UpdateToolContexts(agent *AgentInstance, channel, chatID string) {
// Use ContextualTool interface instead of type assertions
contextualToolNames := []string{"message", "spawn", "subagent"}
for _, name := range contextualToolNames {
if tool, ok := agent.Tools.Get(name); ok {
if ct, ok := tool.(tools.ContextualTool); ok {
ct.SetContext(channel, chatID)
}
}
}
}

View file

@ -86,6 +86,18 @@ var (
rxp(`image exceeds.*mb`), rxp(`image exceeds.*mb`),
} }
// contextWindowPatterns detects context window / token limit exhaustion errors.
contextWindowPatterns = []errorPattern{
rxp(`context.*(window|length).*exceed`),
rxp(`max.*token.*(limit|exceed)`),
rxp(`total tokens.*exceed`),
substr("context_length_exceeded"),
substr("maximum context length"),
substr("invalidparameter"),
rxp(`tokens?.*exceed`),
rxp(`exceeds? the (model|max).*token`),
}
// Transient HTTP status codes that map to timeout (server-side failures). // Transient HTTP status codes that map to timeout (server-side failures).
transientStatusCodes = map[int]bool{ transientStatusCodes = map[int]bool{
500: true, 502: true, 503: true, 500: true, 502: true, 503: true,
@ -225,6 +237,17 @@ func IsImageSizeError(msg string) bool {
return matchesAny(msg, imageSizePatterns) return matchesAny(msg, imageSizePatterns)
} }
// IsContextWindowError returns true if the error indicates a context window / token limit
// exhaustion. This is more precise than naive string matching and avoids false positives
// on errors like "invalid authentication token".
func IsContextWindowError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return matchesAny(msg, contextWindowPatterns)
}
// matchesAny checks if msg matches any of the patterns. // matchesAny checks if msg matches any of the patterns.
func matchesAny(msg string, patterns []errorPattern) bool { func matchesAny(msg string, patterns []errorPattern) bool {
for _, p := range patterns { for _, p := range patterns {

View file

@ -35,6 +35,268 @@ type providerSelection struct {
enableWebSearch bool enableWebSearch bool
} }
// providerDefaults holds the default API base URL and a config accessor for a standard HTTP-compatible provider.
type providerDefaults struct {
defaultBase string
getConfig func(cfg *config.Config) (apiKey, apiBase, proxy string)
// hasKey returns true if the provider has credentials configured.
// If nil, checks that getConfig returns a non-empty apiKey.
hasKey func(cfg *config.Config) bool
}
// standardProviderRegistry maps provider names to their defaults.
// Only covers providers that follow the standard pattern: apiKey + apiBase + proxy.
// Special-case providers (CLI, OAuth, Copilot) are handled separately.
var standardProviderRegistry = map[string]providerDefaults{
"groq": {
defaultBase: "https://api.groq.com/openai/v1",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.Groq.APIKey, cfg.Providers.Groq.APIBase, cfg.Providers.Groq.Proxy
},
},
"openrouter": {
defaultBase: "https://openrouter.ai/api/v1",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.OpenRouter.APIKey, cfg.Providers.OpenRouter.APIBase, cfg.Providers.OpenRouter.Proxy
},
},
"zhipu": {
defaultBase: "https://open.bigmodel.cn/api/paas/v4",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.Zhipu.APIKey, cfg.Providers.Zhipu.APIBase, cfg.Providers.Zhipu.Proxy
},
},
"gemini": {
defaultBase: "https://generativelanguage.googleapis.com/v1beta",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.Gemini.APIKey, cfg.Providers.Gemini.APIBase, cfg.Providers.Gemini.Proxy
},
},
"vllm": {
defaultBase: "", // no default base; requires explicit config
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.VLLM.APIKey, cfg.Providers.VLLM.APIBase, cfg.Providers.VLLM.Proxy
},
hasKey: func(cfg *config.Config) bool {
return cfg.Providers.VLLM.APIBase != ""
},
},
"shengsuanyun": {
defaultBase: "https://router.shengsuanyun.com/api/v1",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.ShengSuanYun.APIKey, cfg.Providers.ShengSuanYun.APIBase, cfg.Providers.ShengSuanYun.Proxy
},
},
"nvidia": {
defaultBase: "https://integrate.api.nvidia.com/v1",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.Nvidia.APIKey, cfg.Providers.Nvidia.APIBase, cfg.Providers.Nvidia.Proxy
},
},
"deepseek": {
defaultBase: "https://api.deepseek.com/v1",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.DeepSeek.APIKey, cfg.Providers.DeepSeek.APIBase, cfg.Providers.DeepSeek.Proxy
},
},
"mistral": {
defaultBase: "https://api.mistral.ai/v1",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.Mistral.APIKey, cfg.Providers.Mistral.APIBase, cfg.Providers.Mistral.Proxy
},
},
"ollama": {
defaultBase: "http://localhost:11434/v1",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.Ollama.APIKey, cfg.Providers.Ollama.APIBase, cfg.Providers.Ollama.Proxy
},
},
"moonshot": {
defaultBase: "https://api.moonshot.cn/v1",
getConfig: func(cfg *config.Config) (string, string, string) {
return cfg.Providers.Moonshot.APIKey, cfg.Providers.Moonshot.APIBase, cfg.Providers.Moonshot.Proxy
},
},
}
// providerNameAliases maps alternative provider names to their canonical names in the registry.
var providerNameAliases = map[string]string{
"glm": "zhipu",
"google": "gemini",
}
// applyStandardProvider applies config from a standardProviderRegistry entry to sel.
// Returns true if the provider had credentials configured.
func applyStandardProvider(cfg *config.Config, sel *providerSelection, entry providerDefaults) bool {
apiKey, apiBase, proxy := entry.getConfig(cfg)
hasCredentials := apiKey != ""
if entry.hasKey != nil {
hasCredentials = entry.hasKey(cfg)
}
if !hasCredentials {
return false
}
sel.apiKey = apiKey
sel.apiBase = apiBase
sel.proxy = proxy
if sel.apiBase == "" && entry.defaultBase != "" {
sel.apiBase = entry.defaultBase
}
return true
}
// modelInferenceEntry maps a model name pattern to a provider and optional match function.
type modelInferenceEntry struct {
// matches returns true if this entry should handle the given model name (lowercase) and original model.
matches func(lowerModel, model string, cfg *config.Config) bool
// apply sets up the provider selection. Returns true on success.
apply func(cfg *config.Config, sel *providerSelection) bool
}
// modelInferenceRegistry defines fallback model → provider inference rules.
// Order matters: first match wins.
var modelInferenceRegistry = []modelInferenceEntry{
// Moonshot/Kimi
{
matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "kimi") || strings.Contains(lm, "moonshot") || strings.HasPrefix(m, "moonshot/")) &&
cfg.Providers.Moonshot.APIKey != ""
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
return applyStandardProvider(cfg, sel, standardProviderRegistry["moonshot"])
},
},
// OpenRouter-prefixed models (openrouter/, anthropic/, openai/, meta-llama/, deepseek/, google/)
{
matches: func(_, m string, _ *config.Config) bool {
for _, prefix := range []string{"openrouter/", "anthropic/", "openai/", "meta-llama/", "deepseek/", "google/"} {
if strings.HasPrefix(m, prefix) {
return true
}
}
return false
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
return applyStandardProvider(cfg, sel, standardProviderRegistry["openrouter"])
},
},
// Claude models → Anthropic (with OAuth support)
{
matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "claude") || strings.HasPrefix(m, "anthropic/")) &&
(cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "")
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
sel.apiBase = cfg.Providers.Anthropic.APIBase
if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase
}
sel.providerType = providerTypeClaudeAuth
return true
}
sel.apiKey = cfg.Providers.Anthropic.APIKey
sel.apiBase = cfg.Providers.Anthropic.APIBase
sel.proxy = cfg.Providers.Anthropic.Proxy
if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase
}
return true
},
},
// GPT models → OpenAI (with OAuth/codex-cli support)
{
matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "gpt") || strings.HasPrefix(m, "openai/")) &&
(cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "")
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
sel.providerType = providerTypeCodexCLIToken
return true
}
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
sel.providerType = providerTypeCodexAuth
return true
}
sel.apiKey = cfg.Providers.OpenAI.APIKey
sel.apiBase = cfg.Providers.OpenAI.APIBase
sel.proxy = cfg.Providers.OpenAI.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.openai.com/v1"
}
return true
},
},
// Gemini
{
matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "gemini") || strings.HasPrefix(m, "google/")) && cfg.Providers.Gemini.APIKey != ""
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
return applyStandardProvider(cfg, sel, standardProviderRegistry["gemini"])
},
},
// Zhipu/GLM
{
matches: func(lm, _ string, cfg *config.Config) bool {
return (strings.Contains(lm, "glm") || strings.Contains(lm, "zhipu") || strings.Contains(lm, "zai")) && cfg.Providers.Zhipu.APIKey != ""
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
return applyStandardProvider(cfg, sel, standardProviderRegistry["zhipu"])
},
},
// Groq
{
matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "groq") || strings.HasPrefix(m, "groq/")) && cfg.Providers.Groq.APIKey != ""
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
return applyStandardProvider(cfg, sel, standardProviderRegistry["groq"])
},
},
// Nvidia
{
matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "nvidia") || strings.HasPrefix(m, "nvidia/")) && cfg.Providers.Nvidia.APIKey != ""
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
return applyStandardProvider(cfg, sel, standardProviderRegistry["nvidia"])
},
},
// Ollama
{
matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "ollama") || strings.HasPrefix(m, "ollama/")) && cfg.Providers.Ollama.APIKey != ""
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
return applyStandardProvider(cfg, sel, standardProviderRegistry["ollama"])
},
},
// Mistral
{
matches: func(lm, m string, cfg *config.Config) bool {
return (strings.Contains(lm, "mistral") || strings.HasPrefix(m, "mistral/")) && cfg.Providers.Mistral.APIKey != ""
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
return applyStandardProvider(cfg, sel, standardProviderRegistry["mistral"])
},
},
// VLLM (fallback if API base is configured)
{
matches: func(_, _ string, cfg *config.Config) bool {
return cfg.Providers.VLLM.APIBase != ""
},
apply: func(cfg *config.Config, sel *providerSelection) bool {
return applyStandardProvider(cfg, sel, standardProviderRegistry["vllm"])
},
},
}
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
model := cfg.Agents.Defaults.Model model := cfg.Agents.Defaults.Model
providerName := strings.ToLower(cfg.Agents.Defaults.Provider) providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
@ -47,16 +309,8 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
// First, prefer explicit provider configuration. // First, prefer explicit provider configuration.
if providerName != "" { if providerName != "" {
// Handle special-case providers that have unique instantiation logic.
switch providerName { switch providerName {
case "groq":
if cfg.Providers.Groq.APIKey != "" {
sel.apiKey = cfg.Providers.Groq.APIKey
sel.apiBase = cfg.Providers.Groq.APIBase
sel.proxy = cfg.Providers.Groq.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.groq.com/openai/v1"
}
}
case "openai", "gpt": case "openai", "gpt":
if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
@ -92,58 +346,6 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.apiBase = defaultAnthropicAPIBase sel.apiBase = defaultAnthropicAPIBase
} }
} }
case "openrouter":
if cfg.Providers.OpenRouter.APIKey != "" {
sel.apiKey = cfg.Providers.OpenRouter.APIKey
sel.proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" {
sel.apiBase = cfg.Providers.OpenRouter.APIBase
} else {
sel.apiBase = "https://openrouter.ai/api/v1"
}
}
case "zhipu", "glm":
if cfg.Providers.Zhipu.APIKey != "" {
sel.apiKey = cfg.Providers.Zhipu.APIKey
sel.apiBase = cfg.Providers.Zhipu.APIBase
sel.proxy = cfg.Providers.Zhipu.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
}
}
case "gemini", "google":
if cfg.Providers.Gemini.APIKey != "" {
sel.apiKey = cfg.Providers.Gemini.APIKey
sel.apiBase = cfg.Providers.Gemini.APIBase
sel.proxy = cfg.Providers.Gemini.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
}
}
case "vllm":
if cfg.Providers.VLLM.APIBase != "" {
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase
sel.proxy = cfg.Providers.VLLM.Proxy
}
case "shengsuanyun":
if cfg.Providers.ShengSuanYun.APIKey != "" {
sel.apiKey = cfg.Providers.ShengSuanYun.APIKey
sel.apiBase = cfg.Providers.ShengSuanYun.APIBase
sel.proxy = cfg.Providers.ShengSuanYun.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://router.shengsuanyun.com/api/v1"
}
}
case "nvidia":
if cfg.Providers.Nvidia.APIKey != "" {
sel.apiKey = cfg.Providers.Nvidia.APIKey
sel.apiBase = cfg.Providers.Nvidia.APIBase
sel.proxy = cfg.Providers.Nvidia.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://integrate.api.nvidia.com/v1"
}
}
case "claude-cli", "claude-code", "claudecode": case "claude-cli", "claude-code", "claudecode":
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
if workspace == "" { if workspace == "" {
@ -160,27 +362,6 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.providerType = providerTypeCodexCLI sel.providerType = providerTypeCodexCLI
sel.workspace = workspace sel.workspace = workspace
return sel, nil return sel, nil
case "deepseek":
if cfg.Providers.DeepSeek.APIKey != "" {
sel.apiKey = cfg.Providers.DeepSeek.APIKey
sel.apiBase = cfg.Providers.DeepSeek.APIBase
sel.proxy = cfg.Providers.DeepSeek.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.deepseek.com/v1"
}
if model != "deepseek-chat" && model != "deepseek-reasoner" {
sel.model = "deepseek-chat"
}
}
case "mistral":
if cfg.Providers.Mistral.APIKey != "" {
sel.apiKey = cfg.Providers.Mistral.APIKey
sel.apiBase = cfg.Providers.Mistral.APIBase
sel.proxy = cfg.Providers.Mistral.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.mistral.ai/v1"
}
}
case "github_copilot", "copilot": case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" { if cfg.Providers.GitHubCopilot.APIBase != "" {
@ -190,120 +371,38 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
} }
sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode
return sel, nil return sel, nil
default:
// Try standard provider registry lookup.
canonicalName := providerName
if alias, ok := providerNameAliases[providerName]; ok {
canonicalName = alias
}
if entry, ok := standardProviderRegistry[canonicalName]; ok {
if applyStandardProvider(cfg, &sel, entry) {
// Handle deepseek model override.
if canonicalName == "deepseek" && model != "deepseek-chat" && model != "deepseek-reasoner" {
sel.model = "deepseek-chat"
}
}
}
} }
} }
// Fallback: infer provider from model and configured keys. // Fallback: infer provider from model name and configured keys.
if sel.apiKey == "" && sel.apiBase == "" { if sel.apiKey == "" && sel.apiBase == "" {
switch { matched := false
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": for _, entry := range modelInferenceRegistry {
sel.apiKey = cfg.Providers.Moonshot.APIKey if entry.matches(lowerModel, model, cfg) {
sel.apiBase = cfg.Providers.Moonshot.APIBase entry.apply(cfg, &sel)
sel.proxy = cfg.Providers.Moonshot.Proxy matched = true
if sel.apiBase == "" { break
sel.apiBase = "https://api.moonshot.cn/v1"
} }
case strings.HasPrefix(model, "openrouter/") ||
strings.HasPrefix(model, "anthropic/") ||
strings.HasPrefix(model, "openai/") ||
strings.HasPrefix(model, "meta-llama/") ||
strings.HasPrefix(model, "deepseek/") ||
strings.HasPrefix(model, "google/"):
sel.apiKey = cfg.Providers.OpenRouter.APIKey
sel.proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" {
sel.apiBase = cfg.Providers.OpenRouter.APIBase
} else {
sel.apiBase = "https://openrouter.ai/api/v1"
} }
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
(cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): // Ultimate fallback: try OpenRouter if configured.
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { if !matched {
sel.apiBase = cfg.Providers.Anthropic.APIBase
if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase
}
sel.providerType = providerTypeClaudeAuth
return sel, nil
}
sel.apiKey = cfg.Providers.Anthropic.APIKey
sel.apiBase = cfg.Providers.Anthropic.APIBase
sel.proxy = cfg.Providers.Anthropic.Proxy
if sel.apiBase == "" {
sel.apiBase = defaultAnthropicAPIBase
}
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) &&
(cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
sel.providerType = providerTypeCodexCLIToken
return sel, nil
}
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
sel.providerType = providerTypeCodexAuth
return sel, nil
}
sel.apiKey = cfg.Providers.OpenAI.APIKey
sel.apiBase = cfg.Providers.OpenAI.APIBase
sel.proxy = cfg.Providers.OpenAI.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.openai.com/v1"
}
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
sel.apiKey = cfg.Providers.Gemini.APIKey
sel.apiBase = cfg.Providers.Gemini.APIBase
sel.proxy = cfg.Providers.Gemini.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
}
case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
sel.apiKey = cfg.Providers.Zhipu.APIKey
sel.apiBase = cfg.Providers.Zhipu.APIBase
sel.proxy = cfg.Providers.Zhipu.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
}
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
sel.apiKey = cfg.Providers.Groq.APIKey
sel.apiBase = cfg.Providers.Groq.APIBase
sel.proxy = cfg.Providers.Groq.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.groq.com/openai/v1"
}
case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
sel.apiKey = cfg.Providers.Nvidia.APIKey
sel.apiBase = cfg.Providers.Nvidia.APIBase
sel.proxy = cfg.Providers.Nvidia.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://integrate.api.nvidia.com/v1"
}
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
sel.apiKey = cfg.Providers.Ollama.APIKey
sel.apiBase = cfg.Providers.Ollama.APIBase
sel.proxy = cfg.Providers.Ollama.Proxy
if sel.apiBase == "" {
sel.apiBase = "http://localhost:11434/v1"
}
case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
sel.apiKey = cfg.Providers.Mistral.APIKey
sel.apiBase = cfg.Providers.Mistral.APIBase
sel.proxy = cfg.Providers.Mistral.Proxy
if sel.apiBase == "" {
sel.apiBase = "https://api.mistral.ai/v1"
}
case cfg.Providers.VLLM.APIBase != "":
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase
sel.proxy = cfg.Providers.VLLM.Proxy
default:
if cfg.Providers.OpenRouter.APIKey != "" { if cfg.Providers.OpenRouter.APIKey != "" {
sel.apiKey = cfg.Providers.OpenRouter.APIKey applyStandardProvider(cfg, &sel, standardProviderRegistry["openrouter"])
sel.proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" {
sel.apiBase = cfg.Providers.OpenRouter.APIBase
} else {
sel.apiBase = "https://openrouter.ai/api/v1"
}
} else { } else {
return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model) return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model)
} }
@ -321,3 +420,4 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
return sel, nil return sel, nil
} }

View file

@ -40,6 +40,7 @@ const (
FailoverTimeout FailoverReason = "timeout" FailoverTimeout FailoverReason = "timeout"
FailoverFormat FailoverReason = "format" FailoverFormat FailoverReason = "format"
FailoverOverloaded FailoverReason = "overloaded" FailoverOverloaded FailoverReason = "overloaded"
FailoverContextWindow FailoverReason = "context_window"
FailoverUnknown FailoverReason = "unknown" FailoverUnknown FailoverReason = "unknown"
) )