Add activeRequests.Wait() at the start of Close() so graceful shutdown waits for any running llmWorkerNormal to finish before releasing resources (sessions, MCP, stats). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1503 lines
42 KiB
Go
1503 lines
42 KiB
Go
// PicoClaw - Ultra-lightweight personal AI agent
|
|
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
|
// License: MIT
|
|
//
|
|
// Copyright (c) 2026 PicoClaw contributors
|
|
|
|
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
"github.com/sipeed/picoclaw/pkg/commands"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/constants"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
"github.com/sipeed/picoclaw/pkg/research"
|
|
"github.com/sipeed/picoclaw/pkg/routing"
|
|
"github.com/sipeed/picoclaw/pkg/skills"
|
|
"github.com/sipeed/picoclaw/pkg/state"
|
|
"github.com/sipeed/picoclaw/pkg/tools"
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
"github.com/sipeed/picoclaw/pkg/voice"
|
|
)
|
|
|
|
type AgentLoop struct {
|
|
loopExt // fork-specific fields (see loop_ext.go)
|
|
|
|
bus *bus.MessageBus
|
|
|
|
cfg *config.Config
|
|
|
|
registry *AgentRegistry
|
|
|
|
state *state.Manager
|
|
|
|
running atomic.Bool
|
|
|
|
summarizing sync.Map
|
|
|
|
fallback *providers.FallbackChain
|
|
|
|
channelManager *channels.Manager
|
|
|
|
mediaStore media.MediaStore
|
|
|
|
transcriber voice.Transcriber
|
|
|
|
cmdRegistry *commands.Registry
|
|
|
|
mcp mcpRuntime
|
|
|
|
mu sync.RWMutex
|
|
|
|
providerCache map[string]providers.LLMProvider
|
|
|
|
lastSystemPrompt atomic.Value // string — last system prompt sent to LLM
|
|
|
|
promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read
|
|
|
|
OnStateChange func() // called on plan/session/skills mutations
|
|
|
|
OnUserMessage func() // called when a real user message is processed
|
|
}
|
|
|
|
// processOptions configures how a message is processed
|
|
type processOptions struct {
|
|
SessionKey string // Session identifier for history/context
|
|
|
|
Channel string // Target channel for tool execution
|
|
|
|
ChatID string // Target chat ID for tool execution
|
|
|
|
SenderID string // Current sender ID for dynamic context
|
|
|
|
SenderDisplayName string // Current sender display name for dynamic context
|
|
|
|
UserMessage string // User message content (may include prefix)
|
|
|
|
Media []string // media:// refs from inbound message
|
|
|
|
HistoryMessage string // If set, save this to history instead of UserMessage (for skill compaction)
|
|
|
|
DefaultResponse string // Response when LLM returns empty
|
|
|
|
EnableSummary bool // Whether to trigger summarization
|
|
|
|
SendResponse bool // Whether to send response via bus
|
|
|
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
|
|
|
TaskID string // Unique task ID for background task status tracking
|
|
|
|
Background bool // If true, this is a background task (cron/heartbeat) — enables live task notifications
|
|
|
|
SystemMessage bool // If true, this is a system message (subagent result) — skip placeholder and plan nudge
|
|
}
|
|
|
|
const (
|
|
defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
|
sessionKeyAgentPrefix = "agent:"
|
|
metadataKeyAccountID = "account_id"
|
|
metadataKeyGuildID = "guild_id"
|
|
metadataKeyTeamID = "team_id"
|
|
metadataKeyParentPeerKind = "parent_peer_kind"
|
|
metadataKeyParentPeerID = "parent_peer_id"
|
|
)
|
|
|
|
func NewAgentLoop(
|
|
cfg *config.Config,
|
|
msgBus *bus.MessageBus,
|
|
provider providers.LLMProvider,
|
|
|
|
enableStats ...bool,
|
|
) *AgentLoop {
|
|
registry := NewAgentRegistry(cfg, provider)
|
|
|
|
// Set up shared fallback chain
|
|
cooldown := providers.NewCooldownTracker()
|
|
fallbackChain := providers.NewFallbackChain(cooldown)
|
|
|
|
// Create state manager using default agent's workspace for channel recording
|
|
defaultAgent := registry.GetDefaultAgent()
|
|
var stateManager *state.Manager
|
|
if defaultAgent != nil {
|
|
stateManager = state.NewManager(defaultAgent.Workspace)
|
|
}
|
|
|
|
providerCache := make(map[string]providers.LLMProvider)
|
|
|
|
al := &AgentLoop{
|
|
bus: msgBus,
|
|
|
|
cfg: cfg,
|
|
|
|
registry: registry,
|
|
|
|
state: stateManager,
|
|
|
|
summarizing: sync.Map{},
|
|
|
|
fallback: fallbackChain,
|
|
|
|
providerCache: providerCache,
|
|
|
|
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
|
}
|
|
|
|
// Initialize fork-specific fields (stats, sessions, orchestration, gcLoop).
|
|
al.initLoopExt(cfg, registry, len(enableStats) > 0 && enableStats[0])
|
|
|
|
// Register shared tools to all agents (needs al for orchestration reporter).
|
|
registerSharedTools(cfg, msgBus, registry, provider, al)
|
|
|
|
return al
|
|
}
|
|
|
|
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
|
func registerSharedTools(
|
|
cfg *config.Config,
|
|
msgBus *bus.MessageBus,
|
|
registry *AgentRegistry,
|
|
provider providers.LLMProvider,
|
|
al *AgentLoop,
|
|
) {
|
|
allowReadPaths := buildAllowReadPatterns(cfg)
|
|
|
|
for _, agentID := range registry.ListAgentIDs() {
|
|
agent, ok := registry.GetAgent(agentID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Web tools
|
|
if cfg.Tools.IsToolEnabled("web") {
|
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
|
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
|
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
|
TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
|
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
|
PerplexityAPIKeys: config.MergeAPIKeys(
|
|
cfg.Tools.Web.Perplexity.APIKey,
|
|
cfg.Tools.Web.Perplexity.APIKeys,
|
|
),
|
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
|
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
|
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
|
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
|
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
|
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
|
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
|
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
|
Proxy: cfg.Tools.Web.Proxy,
|
|
})
|
|
if err != nil {
|
|
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{
|
|
"agent_id": agentID,
|
|
"error": err.Error(),
|
|
})
|
|
} else if searchTool != nil {
|
|
agent.Tools.Register(searchTool)
|
|
|
|
logger.InfoCF("agent", "Web search provider registered", map[string]any{
|
|
"agent_id": agentID,
|
|
"provider": searchTool.ProviderName(),
|
|
})
|
|
} else {
|
|
logger.WarnCF("agent", "No web search provider configured", map[string]any{
|
|
"agent_id": agentID,
|
|
})
|
|
}
|
|
}
|
|
|
|
if cfg.Tools.IsToolEnabled("web_fetch") {
|
|
fetchTool, err := tools.NewWebFetchToolWithProxy(
|
|
50000,
|
|
cfg.Tools.Web.Proxy,
|
|
cfg.Tools.Web.Format,
|
|
cfg.Tools.Web.FetchLimitBytes,
|
|
cfg.Tools.Web.PrivateHostWhitelist)
|
|
if err != nil {
|
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{
|
|
"agent_id": agentID,
|
|
"error": err.Error(),
|
|
})
|
|
} else {
|
|
agent.Tools.Register(fetchTool)
|
|
}
|
|
}
|
|
|
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
|
if cfg.Tools.IsToolEnabled("i2c") {
|
|
agent.Tools.Register(tools.NewI2CTool())
|
|
}
|
|
if cfg.Tools.IsToolEnabled("spi") {
|
|
agent.Tools.Register(tools.NewSPITool())
|
|
}
|
|
|
|
// Message tool
|
|
if cfg.Tools.IsToolEnabled("message") {
|
|
messageTool := tools.NewMessageTool()
|
|
|
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
defer pubCancel()
|
|
|
|
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
|
Channel: channel,
|
|
|
|
ChatID: chatID,
|
|
|
|
Content: content,
|
|
})
|
|
})
|
|
|
|
agent.Tools.Register(messageTool)
|
|
}
|
|
|
|
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
|
if cfg.Tools.IsToolEnabled("send_file") {
|
|
sendFileTool := tools.NewSendFileTool(
|
|
agent.Workspace,
|
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
|
cfg.Agents.Defaults.GetMaxMediaSize(),
|
|
nil,
|
|
allowReadPaths,
|
|
)
|
|
agent.Tools.Register(sendFileTool)
|
|
}
|
|
|
|
// Skill discovery and installation tools
|
|
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
|
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
|
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
|
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
|
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
|
})
|
|
|
|
if find_skills_enable {
|
|
searchCache := skills.NewSearchCache(
|
|
cfg.Tools.Skills.SearchCache.MaxSize,
|
|
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
|
)
|
|
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
|
}
|
|
|
|
if install_skills_enable {
|
|
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
|
}
|
|
}
|
|
|
|
// Orchestration tools (spawn, subagent, answer, review_plan)
|
|
registerOrchestrationTools(cfg, agent, agentID, registry, provider, msgBus, al)
|
|
|
|
// Update context builder with the complete tools registry
|
|
agent.ContextBuilder.SetToolsRegistry(agent.Tools)
|
|
}
|
|
}
|
|
|
|
func (al *AgentLoop) Run(ctx context.Context) error {
|
|
al.running.Store(true)
|
|
|
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// LLM work is dispatched to a background worker so the main loop
|
|
|
|
// stays free to handle slash commands (/skills, …) instantly,
|
|
|
|
// even while a long tool-call chain is running.
|
|
|
|
llmQueue := make(chan bus.InboundMessage, 10)
|
|
|
|
workerDone := make(chan struct{})
|
|
|
|
go func() {
|
|
defer close(workerDone)
|
|
|
|
al.llmWorker(ctx, llmQueue)
|
|
}()
|
|
|
|
defer func() {
|
|
close(llmQueue)
|
|
|
|
<-workerDone
|
|
}()
|
|
|
|
inbound := al.bus.InboundChan()
|
|
for al.running.Load() {
|
|
var msg bus.InboundMessage
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case m, ok := <-inbound:
|
|
if !ok {
|
|
return nil
|
|
}
|
|
msg = m
|
|
}
|
|
|
|
// Echo commands sent from the Mini App so the user can see what was sent.
|
|
|
|
if msg.Metadata["source"] == "webapp" && msg.Metadata["echoed"] == "" && msg.Content != "" {
|
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
|
Channel: msg.Channel,
|
|
|
|
ChatID: msg.ChatID,
|
|
|
|
Content: "via MiniApp: " + msg.Content,
|
|
|
|
SkipPlaceholder: true,
|
|
})
|
|
}
|
|
|
|
// Fast path: handle slash commands immediately without blocking the LLM worker.
|
|
defaultAgent := al.registry.GetDefaultAgent()
|
|
if response, handled := al.handleCommand(ctx, msg, defaultAgent, msg.SessionKey); handled {
|
|
if response != "" {
|
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
|
Channel: msg.Channel,
|
|
|
|
ChatID: msg.ChatID,
|
|
|
|
Content: response,
|
|
|
|
SkipPlaceholder: true,
|
|
})
|
|
}
|
|
|
|
// /plan start sets the flag — enqueue a synthetic message so
|
|
|
|
// the LLM worker actually begins executing the plan.
|
|
|
|
if al.planStartPending {
|
|
al.planStartPending = false
|
|
|
|
clearHistory := al.planClearHistory
|
|
|
|
al.planClearHistory = false
|
|
|
|
if clearHistory {
|
|
if agent := al.registry.GetDefaultAgent(); agent != nil {
|
|
agent.Sessions.SetHistory(msg.SessionKey, nil)
|
|
|
|
agent.Sessions.SetSummary(msg.SessionKey, "")
|
|
|
|
_ = agent.Sessions.Save(msg.SessionKey)
|
|
}
|
|
}
|
|
|
|
// Activate worktree for the session's plan execution
|
|
|
|
if agent := al.registry.GetDefaultAgent(); agent != nil {
|
|
taskName := agent.ContextBuilder.Memory().GetPlanTaskName()
|
|
|
|
if taskName == "" {
|
|
taskName = "plan-execution"
|
|
}
|
|
|
|
planDir := agent.ContextBuilder.GetPlanWorkDir()
|
|
|
|
if wt, err := agent.ActivateWorktree(msg.SessionKey, taskName, planDir); err != nil {
|
|
logger.WarnCF("agent", "Worktree activation skipped", map[string]any{"error": err.Error()})
|
|
} else {
|
|
logger.InfoCF("agent", "Worktree activated", map[string]any{"branch": wt.Branch})
|
|
}
|
|
}
|
|
|
|
syntheticMeta := map[string]string{"echoed": "1"}
|
|
|
|
for k, v := range msg.Metadata {
|
|
if k != "source" {
|
|
syntheticMeta[k] = v
|
|
}
|
|
}
|
|
|
|
select {
|
|
case llmQueue <- bus.InboundMessage{
|
|
Channel: msg.Channel,
|
|
|
|
ChatID: msg.ChatID,
|
|
|
|
SenderID: msg.SenderID,
|
|
|
|
SessionKey: msg.SessionKey,
|
|
|
|
Content: "The plan has been approved. Begin executing.",
|
|
|
|
Metadata: syntheticMeta,
|
|
}:
|
|
|
|
case <-ctx.Done():
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
// Dispatch to LLM worker
|
|
|
|
select {
|
|
case llmQueue <- msg:
|
|
|
|
case <-ctx.Done():
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// llmWorker processes LLM messages sequentially in a background goroutine.
|
|
|
|
func (al *AgentLoop) llmWorker(ctx context.Context, queue <-chan bus.InboundMessage) {
|
|
for msg := range queue {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
// PDF two-phase handling:
|
|
// Phase 1: wait briefly for OCR keyword follow-up ("figures"/"図版")
|
|
// Phase 2: buffer messages during OCR, support cancel
|
|
if messageHasBareFile(msg) {
|
|
al.llmWorkerPDF(ctx, msg, queue)
|
|
continue
|
|
}
|
|
|
|
al.llmWorkerNormal(ctx, msg)
|
|
}
|
|
}
|
|
|
|
// llmWorkerNormal processes a single non-PDF message.
|
|
func (al *AgentLoop) llmWorkerNormal(ctx context.Context, msg bus.InboundMessage) {
|
|
al.activeRequests.Add(1)
|
|
defer al.activeRequests.Done()
|
|
|
|
// Ensure typing indicator is stopped when processing completes.
|
|
if al.channelManager != nil {
|
|
defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
|
|
}
|
|
|
|
// Reset per-round message-tool state so a previous round's
|
|
// tool-sent flag does not suppress this round's response.
|
|
if defaultAgent := al.registry.GetDefaultAgent(); defaultAgent != nil {
|
|
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
|
if mt, ok := tool.(*tools.MessageTool); ok {
|
|
mt.ResetSentInRound()
|
|
}
|
|
}
|
|
}
|
|
|
|
response, err := al.processMessage(ctx, msg)
|
|
if err != nil {
|
|
response = fmt.Sprintf("Error processing message: %v", err)
|
|
}
|
|
|
|
al.sendResponseIfNeeded(ctx, msg, response)
|
|
}
|
|
|
|
// llmWorkerPDF handles a bare-PDF message with two-phase follow-up collection.
|
|
func (al *AgentLoop) llmWorkerPDF(ctx context.Context, msg bus.InboundMessage, queue <-chan bus.InboundMessage) {
|
|
// Phase 1: wait for OCR keywords (figures/図版) — up to 5 seconds.
|
|
var overflow []bus.InboundMessage
|
|
msg, overflow = al.waitForPDFFollowUp(ctx, msg, queue)
|
|
|
|
// Phase 2: run processMessage (OCR) concurrently while buffering
|
|
// messages from the same chat. Cancel on "中止"/"cancel".
|
|
al.resetMessageTool()
|
|
|
|
response, err, buffered := al.processPDFWithBuffering(ctx, msg, queue, overflow)
|
|
if err != nil {
|
|
response = fmt.Sprintf("Error processing message: %v", err)
|
|
}
|
|
|
|
al.sendResponseIfNeeded(ctx, msg, response)
|
|
|
|
// Process buffered same-chat messages as a single follow-up turn
|
|
// so the LLM sees user instructions alongside the OCR result.
|
|
followUpText := mergeBufferedMessages(buffered, msg.ChatID)
|
|
if followUpText != "" {
|
|
notice := formatBufferedNotice(len(buffered))
|
|
if notice != "" {
|
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
|
Channel: msg.Channel,
|
|
ChatID: msg.ChatID,
|
|
Content: notice,
|
|
SkipPlaceholder: true,
|
|
IsStatus: true,
|
|
})
|
|
}
|
|
|
|
followUpMsg := bus.InboundMessage{
|
|
Channel: msg.Channel,
|
|
SenderID: msg.SenderID,
|
|
Sender: msg.Sender,
|
|
ChatID: msg.ChatID,
|
|
Peer: msg.Peer,
|
|
SessionKey: msg.SessionKey,
|
|
Content: followUpText,
|
|
Metadata: msg.Metadata,
|
|
}
|
|
al.llmWorkerNormal(ctx, followUpMsg)
|
|
}
|
|
|
|
// Re-queue messages from other chats that were buffered.
|
|
otherMsgs := extractNonChatMessages(buffered, msg.ChatID)
|
|
for _, other := range otherMsgs {
|
|
al.llmWorkerNormal(ctx, other)
|
|
}
|
|
}
|
|
|
|
// sendResponseIfNeeded sends the LLM response unless the message tool
|
|
// already sent it during this round.
|
|
func (al *AgentLoop) sendResponseIfNeeded(ctx context.Context, msg bus.InboundMessage, response string) {
|
|
if response == "" {
|
|
return
|
|
}
|
|
|
|
alreadySent := false
|
|
if defaultAgent := al.registry.GetDefaultAgent(); defaultAgent != nil {
|
|
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
|
if mt, ok := tool.(*tools.MessageTool); ok {
|
|
alreadySent = mt.HasSentInRound()
|
|
}
|
|
}
|
|
}
|
|
|
|
if !alreadySent {
|
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
|
Channel: msg.Channel,
|
|
ChatID: msg.ChatID,
|
|
Content: response,
|
|
})
|
|
}
|
|
}
|
|
|
|
// resetMessageTool resets the per-round message-tool state.
|
|
func (al *AgentLoop) resetMessageTool() {
|
|
if defaultAgent := al.registry.GetDefaultAgent(); defaultAgent != nil {
|
|
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
|
if mt, ok := tool.(*tools.MessageTool); ok {
|
|
mt.ResetSentInRound()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (al *AgentLoop) Stop() {
|
|
al.running.Store(false)
|
|
}
|
|
|
|
// Close releases resources held by the loop (e.g. flushes write-behind stats
|
|
|
|
// and dirty session data). Should be called during graceful shutdown.
|
|
|
|
func (al *AgentLoop) Close() {
|
|
// Wait for in-flight LLM requests to finish before releasing resources.
|
|
al.activeRequests.Wait()
|
|
|
|
al.closeExt()
|
|
|
|
mcpManager := al.mcp.takeManager()
|
|
if mcpManager != nil {
|
|
if err := mcpManager.Close(); err != nil {
|
|
logger.ErrorCF("agent", "Failed to close MCP manager",
|
|
map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
al.GetRegistry().Close()
|
|
}
|
|
|
|
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
|
registry := al.GetRegistry()
|
|
for _, agentID := range registry.ListAgentIDs() {
|
|
if agent, ok := registry.GetAgent(agentID); ok {
|
|
agent.Tools.Register(tool)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
|
al.channelManager = cm
|
|
}
|
|
|
|
// resolveProvider returns the LLMProvider for the given provider/model pair.
|
|
// It caches created providers by "provider/model" key so each combination is
|
|
// only resolved once. Looks up model_list first (new format), then falls back
|
|
// to the legacy providers section via CreateProviderByName.
|
|
func (al *AgentLoop) resolveProvider(
|
|
providerName, modelName string,
|
|
fallback providers.LLMProvider,
|
|
) providers.LLMProvider {
|
|
key := strings.ToLower(providerName + "/" + modelName)
|
|
|
|
if key == "/" {
|
|
return fallback
|
|
}
|
|
|
|
if p, ok := al.providerCache[key]; ok {
|
|
return p
|
|
}
|
|
|
|
// Try model_list first (new config format).
|
|
if mc := al.cfg.FindModelConfigByRef(providerName, modelName); mc != nil {
|
|
p, _, err := providers.CreateProviderFromConfig(mc)
|
|
if err == nil {
|
|
al.providerCache[key] = p
|
|
return p
|
|
}
|
|
|
|
logger.WarnCF("agent", "Failed to create provider from model_list, trying legacy",
|
|
map[string]any{"provider": providerName, "model": modelName, "error": err.Error()})
|
|
}
|
|
|
|
// Fall back to legacy providers section.
|
|
p, err := providers.CreateProviderByName(al.cfg, providerName)
|
|
if err != nil {
|
|
logger.WarnCF("agent", "Failed to create provider for fallback, using primary",
|
|
map[string]any{"provider": providerName, "error": err.Error()})
|
|
return fallback
|
|
}
|
|
|
|
al.providerCache[key] = p
|
|
return p
|
|
}
|
|
|
|
// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization.
|
|
// It uses a context to allow timeout control from the caller.
|
|
// Returns an error if the reload fails or context is canceled.
|
|
func (al *AgentLoop) ReloadProviderAndConfig(
|
|
ctx context.Context,
|
|
provider providers.LLMProvider,
|
|
cfg *config.Config,
|
|
) error {
|
|
// Validate inputs
|
|
if provider == nil {
|
|
return fmt.Errorf("provider cannot be nil")
|
|
}
|
|
if cfg == nil {
|
|
return fmt.Errorf("config cannot be nil")
|
|
}
|
|
|
|
// Create new registry with updated config and provider
|
|
// Wrap in defer/recover to handle any panics gracefully
|
|
var registry *AgentRegistry
|
|
var panicErr error
|
|
done := make(chan struct{}, 1)
|
|
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
panicErr = fmt.Errorf("panic during registry creation: %v", r)
|
|
logger.ErrorCF("agent", "Panic during registry creation",
|
|
map[string]any{"panic": r})
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
registry = NewAgentRegistry(cfg, provider)
|
|
}()
|
|
|
|
// Wait for completion or context cancellation
|
|
select {
|
|
case <-done:
|
|
if registry == nil {
|
|
if panicErr != nil {
|
|
return fmt.Errorf("registry creation failed: %w", panicErr)
|
|
}
|
|
return fmt.Errorf("registry creation failed (nil result)")
|
|
}
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("context canceled during registry creation: %w", ctx.Err())
|
|
}
|
|
|
|
// Check context again before proceeding
|
|
if err := ctx.Err(); err != nil {
|
|
return fmt.Errorf("context canceled after registry creation: %w", err)
|
|
}
|
|
|
|
// Ensure shared tools are re-registered on the new registry
|
|
registerSharedTools(cfg, al.bus, registry, provider, al)
|
|
|
|
// Atomically swap the config and registry under write lock
|
|
// This ensures readers see a consistent pair
|
|
al.mu.Lock()
|
|
oldRegistry := al.registry
|
|
|
|
// Store new values
|
|
al.cfg = cfg
|
|
al.registry = registry
|
|
|
|
// Also update fallback chain with new config
|
|
al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker())
|
|
|
|
al.mu.Unlock()
|
|
|
|
// Close old provider after releasing the lock
|
|
// This prevents blocking readers while closing
|
|
if oldProvider, ok := extractProvider(oldRegistry); ok {
|
|
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
|
|
// Give in-flight requests a moment to complete
|
|
// Use a reasonable timeout that balances cleanup vs resource usage
|
|
select {
|
|
case <-time.After(100 * time.Millisecond):
|
|
stateful.Close()
|
|
case <-ctx.Done():
|
|
// Context canceled, close immediately but log warning
|
|
logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close",
|
|
map[string]any{"error": ctx.Err()})
|
|
stateful.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.InfoCF("agent", "Provider and config reloaded successfully",
|
|
map[string]any{
|
|
"model": cfg.Agents.Defaults.GetModelName(),
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetRegistry returns the current registry (thread-safe)
|
|
func (al *AgentLoop) GetRegistry() *AgentRegistry {
|
|
al.mu.RLock()
|
|
defer al.mu.RUnlock()
|
|
return al.registry
|
|
}
|
|
|
|
// GetConfig returns the current config (thread-safe)
|
|
func (al *AgentLoop) GetConfig() *config.Config {
|
|
al.mu.RLock()
|
|
defer al.mu.RUnlock()
|
|
return al.cfg
|
|
}
|
|
|
|
// SetMediaStore injects a MediaStore for media lifecycle management.
|
|
|
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
|
al.mediaStore = s
|
|
|
|
// Propagate store to send_file tools in all agents.
|
|
registry := al.GetRegistry()
|
|
registry.ForEachTool("send_file", func(t tools.Tool) {
|
|
if sf, ok := t.(*tools.SendFileTool); ok {
|
|
sf.SetMediaStore(s)
|
|
}
|
|
})
|
|
}
|
|
|
|
// SetTranscriber injects a voice transcriber for agent-level audio transcription.
|
|
func (al *AgentLoop) SetTranscriber(t voice.Transcriber) {
|
|
al.transcriber = t
|
|
}
|
|
|
|
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
|
|
|
|
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
|
|
// replaces audio annotations in msg.Content with the transcribed text.
|
|
// Returns the (possibly modified) message and true if audio was transcribed.
|
|
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) {
|
|
if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
|
|
return msg, false
|
|
}
|
|
|
|
// Transcribe each audio media ref in order.
|
|
var transcriptions []string
|
|
for _, ref := range msg.Media {
|
|
path, meta, err := al.mediaStore.ResolveWithMeta(ref)
|
|
if err != nil {
|
|
logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err})
|
|
continue
|
|
}
|
|
if !utils.IsAudioFile(meta.Filename, meta.ContentType) {
|
|
continue
|
|
}
|
|
result, err := al.transcriber.Transcribe(ctx, path)
|
|
if err != nil {
|
|
logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err})
|
|
transcriptions = append(transcriptions, "")
|
|
continue
|
|
}
|
|
transcriptions = append(transcriptions, result.Text)
|
|
}
|
|
|
|
if len(transcriptions) == 0 {
|
|
return msg, false
|
|
}
|
|
|
|
al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions)
|
|
|
|
// Replace audio annotations sequentially with transcriptions.
|
|
idx := 0
|
|
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
|
|
if idx >= len(transcriptions) {
|
|
return match
|
|
}
|
|
text := transcriptions[idx]
|
|
idx++
|
|
return "[voice: " + text + "]"
|
|
})
|
|
|
|
// Append any remaining transcriptions not matched by an annotation.
|
|
for ; idx < len(transcriptions); idx++ {
|
|
newContent += "\n[voice: " + transcriptions[idx] + "]"
|
|
}
|
|
|
|
msg.Content = newContent
|
|
return msg, true
|
|
}
|
|
|
|
// sendTranscriptionFeedback sends feedback to the user with the result of
|
|
// audio transcription if the option is enabled. It uses Manager.SendMessage
|
|
// which executes synchronously (rate limiting, splitting, retry) so that
|
|
// ordering with the subsequent placeholder is guaranteed.
|
|
func (al *AgentLoop) sendTranscriptionFeedback(
|
|
ctx context.Context,
|
|
channel, chatID, messageID string,
|
|
validTexts []string,
|
|
) {
|
|
if !al.cfg.Voice.EchoTranscription {
|
|
return
|
|
}
|
|
if al.channelManager == nil {
|
|
return
|
|
}
|
|
|
|
var nonEmpty []string
|
|
for _, t := range validTexts {
|
|
if t != "" {
|
|
nonEmpty = append(nonEmpty, t)
|
|
}
|
|
}
|
|
|
|
var feedbackMsg string
|
|
if len(nonEmpty) > 0 {
|
|
feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n")
|
|
} else {
|
|
feedbackMsg = "No voice detected in the audio"
|
|
}
|
|
|
|
err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
Content: feedbackMsg,
|
|
ReplyToMessageID: messageID,
|
|
})
|
|
if err != nil {
|
|
logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
|
// from a filename and MIME content type.
|
|
func inferMediaType(filename, contentType string) string {
|
|
ct := strings.ToLower(contentType)
|
|
fn := strings.ToLower(filename)
|
|
|
|
if strings.HasPrefix(ct, "image/") {
|
|
return "image"
|
|
}
|
|
if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" {
|
|
return "audio"
|
|
}
|
|
if strings.HasPrefix(ct, "video/") {
|
|
return "video"
|
|
}
|
|
|
|
// Fallback: infer from extension
|
|
ext := filepath.Ext(fn)
|
|
switch ext {
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
|
return "image"
|
|
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
|
return "audio"
|
|
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
|
return "video"
|
|
}
|
|
|
|
return "file"
|
|
}
|
|
|
|
// RecordLastChannel records the last active channel for this workspace.
|
|
// This uses the atomic state save mechanism to prevent data loss on crash.
|
|
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
|
if al.state == nil {
|
|
return nil
|
|
}
|
|
return al.state.SetLastChannel(channel)
|
|
}
|
|
|
|
// RecordLastChatID records the last active chat ID for this workspace.
|
|
// This uses the atomic state save mechanism to prevent data loss on crash.
|
|
func (al *AgentLoop) RecordLastChatID(chatID string) error {
|
|
if al.state == nil {
|
|
return nil
|
|
}
|
|
return al.state.SetLastChatID(chatID)
|
|
}
|
|
|
|
// RecordLastHeartbeatTarget records the latest heartbeat-safe destination.
|
|
|
|
// This is intentionally separate from LastChannel so heartbeat routing can be
|
|
|
|
// reasoned about and evolved without breaking generic last-activity tracking.
|
|
|
|
func (al *AgentLoop) RecordLastHeartbeatTarget(target string) error {
|
|
if al.state == nil {
|
|
return nil
|
|
}
|
|
|
|
return al.state.SetLastHeartbeatTarget(target)
|
|
}
|
|
|
|
func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) {
|
|
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
|
|
}
|
|
|
|
func (al *AgentLoop) ProcessDirectWithChannel(
|
|
ctx context.Context,
|
|
content, sessionKey, channel, chatID string,
|
|
) (string, error) {
|
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
msg := bus.InboundMessage{
|
|
Channel: channel,
|
|
|
|
SenderID: "cron",
|
|
|
|
ChatID: chatID,
|
|
|
|
Content: content,
|
|
|
|
SessionKey: sessionKey,
|
|
|
|
Metadata: map[string]string{
|
|
"background": "true",
|
|
},
|
|
}
|
|
|
|
return al.processMessage(ctx, msg)
|
|
}
|
|
|
|
// ProcessHeartbeat processes a heartbeat request without session history.
|
|
// Each heartbeat is independent and doesn't accumulate context.
|
|
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
|
|
ctx = tools.WithHeartbeatContext(ctx)
|
|
ctx = tools.WithWebSearchQuota(ctx, research.DefaultHeartbeatSearchQuota)
|
|
|
|
agent := al.GetRegistry().GetDefaultAgent()
|
|
if agent == nil {
|
|
return "", fmt.Errorf("no default agent for heartbeat")
|
|
}
|
|
|
|
heartbeatThreadID := 0
|
|
|
|
if al.cfg != nil {
|
|
heartbeatThreadID = al.cfg.Channels.Telegram.HeartbeatThreadID
|
|
}
|
|
|
|
heartbeatChatID := al.withTelegramThread(channel, chatID, heartbeatThreadID)
|
|
|
|
return al.runAgentLoop(ctx, agent, processOptions{
|
|
SessionKey: "heartbeat",
|
|
|
|
Channel: channel,
|
|
|
|
ChatID: heartbeatChatID,
|
|
|
|
UserMessage: content,
|
|
|
|
DefaultResponse: defaultResponse,
|
|
|
|
EnableSummary: false,
|
|
|
|
SendResponse: false,
|
|
|
|
NoHistory: true, // Don't load session history for heartbeat
|
|
|
|
Background: true, // Enable live task notifications on Telegram
|
|
|
|
})
|
|
}
|
|
|
|
// resolveMessageRoute resolves the agent and routing info for an inbound message.
|
|
// It looks up the agent registry to determine which agent handles the message
|
|
// and resets the message tool context for the new round.
|
|
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
|
|
registry := al.GetRegistry()
|
|
route := registry.ResolveRoute(routing.RouteInput{
|
|
Channel: msg.Channel,
|
|
AccountID: msg.Metadata[metadataKeyAccountID],
|
|
Peer: extractPeer(msg),
|
|
ParentPeer: extractParentPeer(msg),
|
|
GuildID: msg.Metadata[metadataKeyGuildID],
|
|
TeamID: msg.Metadata[metadataKeyTeamID],
|
|
})
|
|
|
|
agent, ok := registry.GetAgent(route.AgentID)
|
|
if !ok {
|
|
agent = registry.GetDefaultAgent()
|
|
}
|
|
if agent == nil {
|
|
return route, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
|
}
|
|
|
|
// Reset message-tool state for this round
|
|
if tool, ok := agent.Tools.Get("message"); ok {
|
|
if mt, ok := tool.(tools.ContextualTool); ok {
|
|
mt.SetContext(msg.Channel, msg.ChatID)
|
|
}
|
|
}
|
|
|
|
logger.InfoCF("agent", "Routed message",
|
|
map[string]any{
|
|
"agent_id": agent.ID,
|
|
"session_key": route.SessionKey,
|
|
"matched_by": route.MatchedBy,
|
|
})
|
|
|
|
return route, agent, nil
|
|
}
|
|
|
|
// resolveScopeKey returns the session key to use: honors a pre-set key (from
|
|
// ProcessDirect/cron) over the route-resolved key.
|
|
func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string {
|
|
if msgSessionKey != "" {
|
|
return msgSessionKey
|
|
}
|
|
return route.SessionKey
|
|
}
|
|
|
|
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
|
// Add message preview to log (show full content for error messages)
|
|
var logContent string
|
|
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
|
|
logContent = msg.Content // Full content for errors
|
|
} else {
|
|
logContent = utils.Truncate(msg.Content, 80)
|
|
}
|
|
|
|
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
|
|
|
|
map[string]any{
|
|
"channel": msg.Channel,
|
|
|
|
"chat_id": msg.ChatID,
|
|
|
|
"sender_id": msg.SenderID,
|
|
|
|
"session_key": msg.SessionKey,
|
|
})
|
|
|
|
// Transcribe audio in the message if a transcriber is configured.
|
|
var hadAudio bool
|
|
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
|
|
|
|
// For audio messages the placeholder was deferred by the channel.
|
|
// Now that transcription (and optional feedback) is done, send it.
|
|
if hadAudio && al.channelManager != nil {
|
|
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
|
|
}
|
|
|
|
// Handle reply-based intervention for active tasks
|
|
if response, handled := al.handleTaskIntervention(msg); handled {
|
|
return response, nil
|
|
}
|
|
|
|
// Route system messages to processSystemMessage
|
|
if msg.Channel == "system" {
|
|
return al.processSystemMessage(ctx, msg)
|
|
}
|
|
|
|
// Notify listeners that a real user message arrived (e.g. reset heartbeat suppression)
|
|
|
|
if al.OnUserMessage != nil {
|
|
al.OnUserMessage()
|
|
}
|
|
|
|
// Expand fork-specific /skill and /plan commands
|
|
expansionCompact := al.expandForkCommands(&msg)
|
|
|
|
// Check for commands (using default agent, before routing)
|
|
if response, handled := al.handleCommand(ctx, msg, al.registry.GetDefaultAgent(), msg.SessionKey); handled {
|
|
return response, nil
|
|
}
|
|
|
|
// Route to determine agent and session key
|
|
route, agent, err := al.resolveMessageRoute(msg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sessionKey := resolveScopeKey(route, msg.SessionKey)
|
|
|
|
return al.runAgentLoop(ctx, agent, processOptions{
|
|
SessionKey: sessionKey,
|
|
|
|
Channel: msg.Channel,
|
|
|
|
ChatID: msg.ChatID,
|
|
|
|
SenderID: msg.SenderID,
|
|
|
|
SenderDisplayName: msg.Sender.DisplayName,
|
|
|
|
UserMessage: msg.Content,
|
|
|
|
Media: msg.Media,
|
|
|
|
HistoryMessage: expansionCompact,
|
|
|
|
DefaultResponse: defaultResponse,
|
|
|
|
EnableSummary: true,
|
|
|
|
SendResponse: false,
|
|
|
|
Background: msg.Metadata["background"] == "true",
|
|
})
|
|
}
|
|
|
|
func (al *AgentLoop) withTelegramThread(channel, chatID string, threadID int) string {
|
|
if channel != "telegram" || threadID <= 0 || chatID == "" {
|
|
return chatID
|
|
}
|
|
|
|
baseChatID := chatID
|
|
|
|
if slash := strings.Index(baseChatID, "/"); slash >= 0 {
|
|
baseChatID = baseChatID[:slash]
|
|
}
|
|
|
|
if baseChatID == "" {
|
|
return chatID
|
|
}
|
|
|
|
return fmt.Sprintf("%s/%d", baseChatID, threadID)
|
|
}
|
|
|
|
func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
|
|
if al.channelManager == nil {
|
|
return ""
|
|
}
|
|
if ch, ok := al.channelManager.GetChannel(channelName); ok {
|
|
return ch.ReasoningChannelID()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// callLLMWithRetry calls the LLM with streaming support, fallback chain,
|
|
// and retry logic for timeout and context window errors.
|
|
func (al *AgentLoop) callLLMWithRetry(
|
|
ctx context.Context,
|
|
agent *AgentInstance,
|
|
messages *[]providers.Message,
|
|
opts processOptions,
|
|
toolDefs []providers.ToolDefinition,
|
|
candidates []providers.FallbackCandidate,
|
|
activeModel string,
|
|
onChunk func(string, string),
|
|
iteration int,
|
|
) (*providers.LLMResponse, error) {
|
|
llmOpts := map[string]any{
|
|
"max_tokens": agent.MaxTokens,
|
|
"temperature": agent.Temperature,
|
|
"prompt_cache_key": agent.ID,
|
|
}
|
|
|
|
// parseThinkingLevel guarantees ThinkingOff for empty/unknown values,
|
|
// so checking != ThinkingOff is sufficient.
|
|
if agent.ThinkingLevel != ThinkingOff {
|
|
if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
|
|
llmOpts["thinking_level"] = string(agent.ThinkingLevel)
|
|
} else {
|
|
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
|
|
map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)})
|
|
}
|
|
}
|
|
|
|
doCall := func(ctx context.Context, p providers.LLMProvider, model string) (*providers.LLMResponse, error) {
|
|
if sp, ok := p.(providers.StreamingProvider); ok && sp.CanStream() {
|
|
streamCtx, streamCancel := context.WithCancel(ctx)
|
|
defer streamCancel()
|
|
ch, sErr := sp.ChatStream(streamCtx, *messages, toolDefs, model, llmOpts)
|
|
if sErr != nil {
|
|
return nil, sErr
|
|
}
|
|
resp, repetition, sErr := consumeStreamWithRepetitionDetection(ch, streamCancel, 1000, onChunk)
|
|
if sErr != nil {
|
|
return nil, sErr
|
|
}
|
|
if repetition {
|
|
resp.FinishReason = "repetition_detected"
|
|
}
|
|
return resp, nil
|
|
}
|
|
return p.Chat(ctx, *messages, toolDefs, model, llmOpts)
|
|
}
|
|
|
|
callLLM := func() (*providers.LLMResponse, error) {
|
|
if len(candidates) > 1 && al.fallback != nil {
|
|
fbResult, fbErr := al.fallback.Execute(ctx, candidates,
|
|
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
|
p := al.resolveProvider(provider, model, agent.Provider)
|
|
return doCall(ctx, p, model)
|
|
},
|
|
)
|
|
if fbErr != nil {
|
|
return nil, fbErr
|
|
}
|
|
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
|
|
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
|
|
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
|
|
map[string]any{"agent_id": agent.ID, "iteration": iteration})
|
|
}
|
|
return fbResult.Response, nil
|
|
}
|
|
if len(candidates) > 0 {
|
|
c := candidates[0]
|
|
p := al.resolveProvider(c.Provider, c.Model, agent.Provider)
|
|
return doCall(ctx, p, c.Model)
|
|
}
|
|
return doCall(ctx, agent.Provider, activeModel)
|
|
}
|
|
|
|
// Hook: pre-LLM state reporting (called via hooks in the caller)
|
|
|
|
maxRetries := 2
|
|
var response *providers.LLMResponse
|
|
var err error
|
|
|
|
for retry := 0; retry <= maxRetries; retry++ {
|
|
response, err = callLLM()
|
|
if err == nil {
|
|
return response, nil
|
|
}
|
|
|
|
errMsg := strings.ToLower(err.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")
|
|
|
|
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"))
|
|
|
|
if isTimeoutError && retry < maxRetries {
|
|
backoff := time.Duration(retry+1) * 5 * time.Second
|
|
logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
|
|
"error": err.Error(),
|
|
"retry": retry,
|
|
"backoff": backoff.String(),
|
|
})
|
|
time.Sleep(backoff)
|
|
continue
|
|
}
|
|
|
|
if isContextError && retry < maxRetries {
|
|
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
|
|
"error": err.Error(),
|
|
"retry": retry,
|
|
})
|
|
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
|
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
|
Channel: opts.Channel,
|
|
ChatID: opts.ChatID,
|
|
Content: "Context window exceeded. Compressing history and retrying...",
|
|
})
|
|
}
|
|
al.forceCompression(agent, opts.SessionKey)
|
|
newHistory := agent.Sessions.GetHistory(opts.SessionKey)
|
|
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
|
*messages = agent.ContextBuilder.BuildMessages(
|
|
newHistory, newSummary, "",
|
|
nil, opts.Channel, opts.ChatID,
|
|
opts.SenderID, opts.SenderDisplayName,
|
|
)
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// cleanLLMResponse handles repetition detection, think block stripping,
|
|
// and XML tool call extraction on the raw LLM response.
|
|
func (al *AgentLoop) cleanLLMResponse(
|
|
ctx context.Context,
|
|
response *providers.LLMResponse,
|
|
messages *[]providers.Message,
|
|
agent *AgentInstance,
|
|
iteration int,
|
|
toolDefs []providers.ToolDefinition,
|
|
candidates []providers.FallbackCandidate,
|
|
activeModel string,
|
|
onChunk func(string, string),
|
|
) *providers.LLMResponse {
|
|
if response.FinishReason == "repetition_detected" ||
|
|
(len(response.ToolCalls) == 0 && utils.DetectRepetitionLoop(response.Content)) {
|
|
logger.WarnCF("agent", "Repetition loop detected in LLM response, retrying",
|
|
map[string]any{
|
|
"agent_id": agent.ID,
|
|
"iteration": iteration,
|
|
"finish_reason": response.FinishReason,
|
|
"content_length": len(response.Content),
|
|
})
|
|
|
|
savedMsgs := *messages
|
|
*messages = append(append([]providers.Message(nil), *messages...),
|
|
providers.Message{
|
|
Role: "user",
|
|
Content: "[System] Your previous response contained degenerate repetition and was discarded. Please respond normally without repeating yourself.",
|
|
})
|
|
|
|
retryResp, retryErr := al.callLLMWithRetry(ctx, agent, messages, processOptions{},
|
|
toolDefs, candidates, activeModel, onChunk, iteration)
|
|
*messages = savedMsgs
|
|
|
|
if retryErr == nil {
|
|
response = retryResp
|
|
}
|
|
if utils.DetectRepetitionLoop(response.Content) {
|
|
logger.ErrorCF("agent", "Repetition persists after retry, returning empty",
|
|
map[string]any{"agent_id": agent.ID})
|
|
response.Content = ""
|
|
}
|
|
}
|
|
|
|
response.Content = utils.StripThinkBlocks(response.Content)
|
|
if len(response.ToolCalls) == 0 {
|
|
if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 {
|
|
response.ToolCalls = xmlCalls
|
|
}
|
|
}
|
|
response.Content = providers.StripXMLToolCalls(response.Content)
|
|
return response
|
|
}
|
|
|
|
// buildAssistantMessage constructs the assistant message with tool calls.
|
|
func buildAssistantMessage(response *providers.LLMResponse, toolCalls []providers.ToolCall) providers.Message {
|
|
msg := providers.Message{
|
|
Role: "assistant",
|
|
Content: response.Content,
|
|
ReasoningContent: response.ReasoningContent,
|
|
}
|
|
for _, tc := range toolCalls {
|
|
extraContent := tc.ExtraContent
|
|
thoughtSignature := ""
|
|
if tc.Function != nil {
|
|
thoughtSignature = tc.Function.ThoughtSignature
|
|
}
|
|
msg.ToolCalls = append(msg.ToolCalls, providers.ToolCall{
|
|
ID: tc.ID,
|
|
Type: "function",
|
|
Name: tc.Name,
|
|
Arguments: tc.Arguments,
|
|
Function: &providers.FunctionCall{
|
|
Name: tc.Name,
|
|
Arguments: tc.Arguments,
|
|
ThoughtSignature: thoughtSignature,
|
|
},
|
|
ExtraContent: extraContent,
|
|
ThoughtSignature: thoughtSignature,
|
|
})
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// publishToolMedia publishes media refs from a tool result as outbound media.
|
|
func (al *AgentLoop) publishToolMedia(ctx context.Context, result *tools.ToolResult, opts processOptions) {
|
|
parts := make([]bus.MediaPart, 0, len(result.Media))
|
|
for _, ref := range result.Media {
|
|
part := bus.MediaPart{Ref: ref}
|
|
if al.mediaStore != nil {
|
|
if _, meta, mErr := al.mediaStore.ResolveWithMeta(ref); mErr == nil {
|
|
part.Filename = meta.Filename
|
|
part.ContentType = meta.ContentType
|
|
part.Type = inferMediaType(meta.Filename, meta.ContentType)
|
|
}
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
|
|
Channel: opts.Channel,
|
|
ChatID: opts.ChatID,
|
|
Parts: parts,
|
|
})
|
|
}
|
|
|
|
// isNativeSearchProvider reports whether the given LLM provider implements
|
|
// NativeSearchCapable and returns true for SupportsNativeSearch.
|
|
func isNativeSearchProvider(p providers.LLMProvider) bool {
|
|
if ns, ok := p.(providers.NativeSearchCapable); ok {
|
|
return ns.SupportsNativeSearch()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// filterClientWebSearch returns a copy of tools with the client-side
|
|
// web_search tool removed. Used when native provider search is preferred.
|
|
func filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition {
|
|
result := make([]providers.ToolDefinition, 0, len(tools))
|
|
for _, t := range tools {
|
|
if strings.EqualFold(t.Function.Name, "web_search") {
|
|
continue
|
|
}
|
|
result = append(result, t)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Helper to extract provider from registry for cleanup
|
|
func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) {
|
|
if registry == nil {
|
|
return nil, false
|
|
}
|
|
// Get any agent to access the provider
|
|
defaultAgent := registry.GetDefaultAgent()
|
|
if defaultAgent == nil {
|
|
return nil, false
|
|
}
|
|
return defaultAgent.Provider, true
|
|
}
|