package agent import ( "context" "encoding/json" "fmt" "log/slog" "os" "path/filepath" "sort" "strings" "sync" "sync/atomic" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/internal/tracing" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // bootstrapAutoCleanupTurns is the number of user messages after which // BOOTSTRAP.md is auto-removed if the LLM hasn't cleared it. // Bootstrap typically completes in 2-3 conversation turns. const bootstrapAutoCleanupTurns = 3 // EnsureUserFilesFunc seeds per-user context files on first chat (managed mode). // Returns the effective workspace path (from user_agent_profiles) for caching. type EnsureUserFilesFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (effectiveWorkspace string, err error) // ContextFileLoaderFunc loads context files dynamically per-request (managed mode). type ContextFileLoaderFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile // BootstrapCleanupFunc removes BOOTSTRAP.md after a successful first run. // Called automatically so the system doesn't rely on the LLM to delete it. type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID string) error // Loop is the agent execution loop for one agent instance. // Think → Act → Observe cycle with tool execution. type Loop struct { id string agentUUID uuid.UUID // set in managed mode for context propagation agentType string // "open" or "predefined" (managed mode) provider providers.Provider model string contextWindow int maxIterations int workspace string eventPub bus.EventPublisher // currently unused by Loop; kept for future use sessions store.SessionStore tools *tools.Registry toolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM agentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions) activeRuns atomic.Int32 // number of currently executing runs // Per-session summarization lock: prevents concurrent summarize goroutines for the same session. summarizeMu sync.Map // sessionKey → *sync.Mutex // Bootstrap/persona context (loaded at startup, injected into system prompt) ownerIDs []string skillsLoader *skills.Loader skillAllowList []string // nil = all, [] = none, ["x","y"] = filter hasMemory bool contextFiles []bootstrap.ContextFile // Per-user file seeding + dynamic context loading (managed mode) ensureUserFiles EnsureUserFilesFunc contextFileLoader ContextFileLoaderFunc bootstrapCleanup BootstrapCleanupFunc userWorkspaces sync.Map // userID → string (expanded workspace path from user_agent_profiles) // Compaction config (memory flush settings) compactionCfg *config.CompactionConfig // Context pruning config (trim old tool results in-memory) contextPruningCfg *config.ContextPruningConfig // Sandbox info sandboxEnabled bool sandboxContainerDir string sandboxWorkspaceAccess string // Event callback for broadcasting agent events (run.started, chunk, tool.call, etc.) onEvent func(event AgentEvent) // Tracing collector (nil in standalone mode) traceCollector *tracing.Collector // Security: input scanning and message size limit inputGuard *InputGuard injectionAction string // "log", "warn" (default), "block", "off" maxMessageChars int // 0 = use default (32000) // Global builtin tool settings (from builtin_tools table, managed mode) builtinToolSettings tools.BuiltinToolSettings // Thinking level for extended thinking support thinkingLevel string // Group writer cache for system prompt injection (managed mode) groupWriterCache *store.GroupWriterCache } // AgentEvent is emitted during agent execution for WS broadcasting. type AgentEvent struct { Type string `json:"type"` // "run.started", "run.completed", "run.failed", "chunk", "tool.call", "tool.result" AgentID string `json:"agentId"` RunID string `json:"runId"` Payload interface{} `json:"payload,omitempty"` } // LoopConfig configures a new Loop. type LoopConfig struct { ID string Provider providers.Provider Model string ContextWindow int MaxIterations int Workspace string Bus bus.EventPublisher Sessions store.SessionStore Tools *tools.Registry ToolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM AgentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions) OnEvent func(AgentEvent) // Bootstrap/persona context OwnerIDs []string SkillsLoader *skills.Loader SkillAllowList []string // nil = all, [] = none, ["x","y"] = filter HasMemory bool ContextFiles []bootstrap.ContextFile // Compaction config CompactionCfg *config.CompactionConfig // Context pruning (trim old tool results to save context window) ContextPruningCfg *config.ContextPruningConfig // Sandbox info (injected into system prompt) SandboxEnabled bool SandboxContainerDir string // e.g. "/workspace" SandboxWorkspaceAccess string // "none", "ro", "rw" // Managed mode: agent UUID for context propagation to tools AgentUUID uuid.UUID AgentType string // "open" or "predefined" (managed mode) // Per-user file seeding + dynamic context loading (managed mode) EnsureUserFiles EnsureUserFilesFunc ContextFileLoader ContextFileLoaderFunc BootstrapCleanup BootstrapCleanupFunc // Tracing collector (nil = no tracing) TraceCollector *tracing.Collector // Security: input guard for injection detection, max message size InputGuard *InputGuard // nil = auto-create when InjectionAction != "off" InjectionAction string // "log", "warn" (default), "block", "off" MaxMessageChars int // 0 = use default (32000) // Global builtin tool settings (from builtin_tools table, managed mode) BuiltinToolSettings tools.BuiltinToolSettings // Thinking level: "off", "low", "medium", "high" (from agent other_config) ThinkingLevel string // Group writer cache for system prompt injection (managed mode) GroupWriterCache *store.GroupWriterCache } func NewLoop(cfg LoopConfig) *Loop { if cfg.MaxIterations <= 0 { cfg.MaxIterations = 20 } if cfg.ContextWindow <= 0 { cfg.ContextWindow = 200000 } // Normalize injection action (default: "warn") action := cfg.InjectionAction switch action { case "log", "warn", "block", "off": // valid default: action = "warn" } // Auto-create InputGuard unless explicitly disabled guard := cfg.InputGuard if guard == nil && action != "off" { guard = NewInputGuard() } return &Loop{ id: cfg.ID, agentUUID: cfg.AgentUUID, agentType: cfg.AgentType, provider: cfg.Provider, model: cfg.Model, contextWindow: cfg.ContextWindow, maxIterations: cfg.MaxIterations, workspace: cfg.Workspace, eventPub: cfg.Bus, sessions: cfg.Sessions, tools: cfg.Tools, toolPolicy: cfg.ToolPolicy, agentToolPolicy: cfg.AgentToolPolicy, onEvent: cfg.OnEvent, ownerIDs: cfg.OwnerIDs, skillsLoader: cfg.SkillsLoader, skillAllowList: cfg.SkillAllowList, hasMemory: cfg.HasMemory, contextFiles: cfg.ContextFiles, ensureUserFiles: cfg.EnsureUserFiles, contextFileLoader: cfg.ContextFileLoader, bootstrapCleanup: cfg.BootstrapCleanup, compactionCfg: cfg.CompactionCfg, contextPruningCfg: cfg.ContextPruningCfg, sandboxEnabled: cfg.SandboxEnabled, sandboxContainerDir: cfg.SandboxContainerDir, sandboxWorkspaceAccess: cfg.SandboxWorkspaceAccess, traceCollector: cfg.TraceCollector, inputGuard: guard, injectionAction: action, maxMessageChars: cfg.MaxMessageChars, builtinToolSettings: cfg.BuiltinToolSettings, thinkingLevel: cfg.ThinkingLevel, groupWriterCache: cfg.GroupWriterCache, } } // RunRequest is the input for processing a message through the agent. type RunRequest struct { SessionKey string // composite key: agent:{agentId}:{channel}:{peerKind}:{chatId} Message string // user message Media []string // local file paths to images (already sanitized) ForwardMedia []string // media paths to forward to output (not deleted, from delegation results) Channel string // source channel ChatID string // source chat ID PeerKind string // "direct" or "group" (for session key building and tool context) RunID string // unique run identifier UserID string // external user ID (TEXT, free-form) for multi-tenant scoping SenderID string // original individual sender ID (preserved in group chats for permission checks) Stream bool // whether to stream response chunks ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.) SkillFilter []string // per-request skill override: nil=use agent default, []=no skills, ["x","y"]=whitelist HistoryLimit int // max user turns to keep in context (0=unlimited, from channel config) ToolAllow []string // per-group tool allow list (nil = no restriction, supports "group:xxx") LocalKey string // composite key with topic/thread suffix for routing (e.g. "-100123:topic:42") ParentTraceID uuid.UUID // if set, reuse parent trace instead of creating new (announce runs) ParentRootSpanID uuid.UUID // if set, nest announce agent span under this parent span TraceName string // override trace name (default: "chat ") TraceTags []string // additional tags for the trace (e.g. "cron") MaxIterations int // per-request override (0 = use agent default, must be lower) } // RunResult is the output of a completed agent run. type RunResult struct { Content string `json:"content"` RunID string `json:"runId"` Iterations int `json:"iterations"` Usage *providers.Usage `json:"usage,omitempty"` Media []MediaResult `json:"media,omitempty"` // media files from tool results (MEDIA: prefix) Deliverables []string `json:"deliverables,omitempty"` // actual content from tool outputs (for team task results) } // MediaResult represents a media file produced by a tool during the agent run. type MediaResult struct { Path string `json:"path"` // local file path ContentType string `json:"content_type,omitempty"` // MIME type AsVoice bool `json:"as_voice,omitempty"` // send as voice message (Telegram OGG) } // Run processes a single message through the agent loop. // It blocks until completion and returns the final response. func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { l.activeRuns.Add(1) defer l.activeRuns.Add(-1) l.emit(AgentEvent{Type: protocol.AgentEventRunStarted, AgentID: l.id, RunID: req.RunID}) // Create trace (managed mode only) var traceID uuid.UUID isChildTrace := req.ParentTraceID != uuid.Nil && l.traceCollector != nil if isChildTrace { // Announce run: reuse parent trace, don't create new trace record. // Spans will be added to the parent trace with proper nesting. traceID = req.ParentTraceID ctx = tracing.WithTraceID(ctx, traceID) ctx = tracing.WithCollector(ctx, l.traceCollector) ctx = tracing.WithParentSpanID(ctx, store.GenNewID()) if req.ParentRootSpanID != uuid.Nil { ctx = tracing.WithAnnounceParentSpanID(ctx, req.ParentRootSpanID) } } else if l.traceCollector != nil { traceID = store.GenNewID() now := time.Now().UTC() traceName := "chat " + l.id if req.TraceName != "" { traceName = req.TraceName } trace := &store.TraceData{ ID: traceID, RunID: req.RunID, SessionKey: req.SessionKey, UserID: req.UserID, Channel: req.Channel, Name: traceName, InputPreview: truncateStr(req.Message, 500), Status: store.TraceStatusRunning, StartTime: now, CreatedAt: now, Tags: req.TraceTags, } if l.agentUUID != uuid.Nil { trace.AgentID = &l.agentUUID } // Link to parent trace if this is a delegated run if delegateParent := tracing.DelegateParentTraceIDFromContext(ctx); delegateParent != uuid.Nil { trace.ParentTraceID = &delegateParent } if err := l.traceCollector.CreateTrace(ctx, trace); err != nil { slog.Warn("tracing: failed to create trace", "error", err) } else { ctx = tracing.WithTraceID(ctx, traceID) ctx = tracing.WithCollector(ctx, l.traceCollector) // Pre-generate root "agent" span ID so LLM/tool spans can reference it as parent. // The span itself is emitted after runLoop completes (with full timing data). ctx = tracing.WithParentSpanID(ctx, store.GenNewID()) } } // Inject local key into tool context so delegation/subagent tools can // propagate topic/thread routing info back through announce messages. if req.LocalKey != "" { ctx = tools.WithToolLocalKey(ctx, req.LocalKey) } runStart := time.Now().UTC() result, err := l.runLoop(ctx, req) // Emit root "agent" span with full timing (parent for all LLM/tool spans). if l.traceCollector != nil && traceID != uuid.Nil { l.emitAgentSpan(ctx, runStart, result, err) } if err != nil { l.emit(AgentEvent{ Type: protocol.AgentEventRunFailed, AgentID: l.id, RunID: req.RunID, Payload: map[string]string{"error": err.Error()}, }) // Only finish trace for root runs; child traces don't own the trace lifecycle. // Use background context when the run context is cancelled (/stop command) // so the DB update still succeeds. if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { traceCtx := ctx traceStatus := store.TraceStatusError if ctx.Err() != nil { traceCtx = context.Background() traceStatus = store.TraceStatusCancelled } l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, err.Error(), "") } return nil, err } l.emit(AgentEvent{Type: protocol.AgentEventRunCompleted, AgentID: l.id, RunID: req.RunID}) if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", truncateStr(result.Content, 500)) } return result, nil } func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) { // Inject agent UUID into context for tool routing (managed mode) if l.agentUUID != uuid.Nil { ctx = store.WithAgentID(ctx, l.agentUUID) } // Inject user ID into context for per-user scoping (memory, context files, etc.) if req.UserID != "" { ctx = store.WithUserID(ctx, req.UserID) } // Inject agent type into context for interceptor routing (managed mode) if l.agentType != "" { ctx = store.WithAgentType(ctx, l.agentType) } // Inject original sender ID for group file writer permission checks if req.SenderID != "" { ctx = store.WithSenderID(ctx, req.SenderID) } // Inject per-agent vision/imagegen config for read_image/create_image tools if l.agentToolPolicy != nil { if l.agentToolPolicy.Vision != nil { ctx = tools.WithVisionConfig(ctx, l.agentToolPolicy.Vision) } if l.agentToolPolicy.ImageGen != nil { ctx = tools.WithImageGenConfig(ctx, l.agentToolPolicy.ImageGen) } } // Inject global builtin tool settings (DB-level defaults, lower priority than per-agent) if l.builtinToolSettings != nil { ctx = tools.WithBuiltinToolSettings(ctx, l.builtinToolSettings) } // Per-user workspace isolation. // Workspace path comes from user_agent_profiles (includes channel segment // for cross-channel isolation). Cached in userWorkspaces to avoid repeated DB queries. if l.workspace != "" && req.UserID != "" { cachedWs, loaded := l.userWorkspaces.Load(req.UserID) if !loaded { // First request for this user: get/create profile → returns stored workspace. // Also seeds per-user context files on first chat (managed mode). ws := l.workspace if l.ensureUserFiles != nil { var err error ws, err = l.ensureUserFiles(ctx, l.agentUUID, req.UserID, l.agentType, l.workspace, req.Channel) if err != nil { slog.Warn("failed to ensure user context files", "error", err) ws = l.workspace } } // Expand ~ and convert to absolute for filesystem operations. ws = config.ExpandHome(ws) if !filepath.IsAbs(ws) { ws, _ = filepath.Abs(ws) } l.userWorkspaces.Store(req.UserID, ws) cachedWs = ws } effectiveWorkspace := filepath.Join(cachedWs.(string), sanitizePathSegment(req.UserID)) if err := os.MkdirAll(effectiveWorkspace, 0755); err != nil { slog.Warn("failed to create user workspace directory", "workspace", effectiveWorkspace, "user", req.UserID, "error", err) } ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace) } else if l.workspace != "" { ctx = tools.WithToolWorkspace(ctx, l.workspace) } // Persist agent UUID + user ID on the session (for querying/tracing) if l.agentUUID != uuid.Nil || req.UserID != "" { l.sessions.SetAgentInfo(req.SessionKey, l.agentUUID, req.UserID) } // Security: scan user message for injection patterns. // Action is configurable: "log" (info), "warn" (default), "block" (reject message). if l.inputGuard != nil { if matches := l.inputGuard.Scan(req.Message); len(matches) > 0 { matchStr := strings.Join(matches, ",") switch l.injectionAction { case "block": slog.Warn("security.injection_blocked", "agent", l.id, "user", req.UserID, "patterns", matchStr, "message_len", len(req.Message), ) return nil, fmt.Errorf("message blocked: potential prompt injection detected (%s)", matchStr) case "log": slog.Info("security.injection_detected", "agent", l.id, "user", req.UserID, "patterns", matchStr, "message_len", len(req.Message), ) default: // "warn" slog.Warn("security.injection_detected", "agent", l.id, "user", req.UserID, "patterns", matchStr, "message_len", len(req.Message), ) } } } // Inject agent key into context for tool-level resolution (managed mode: multiple agents share tool registry) ctx = tools.WithToolAgentKey(ctx, l.id) // Security: truncate oversized user messages gracefully (feed truncation notice into LLM) maxChars := l.maxMessageChars if maxChars <= 0 { maxChars = 32_000 // default ~8-10K tokens } if len(req.Message) > maxChars { originalLen := len(req.Message) req.Message = req.Message[:maxChars] + fmt.Sprintf("\n\n[System: Message was truncated from %d to %d characters due to size limit. "+ "Please ask the user to send shorter messages or use the read_file tool for large content.]", originalLen, maxChars) slog.Warn("security.message_truncated", "agent", l.id, "user", req.UserID, "original_len", originalLen, "truncated_to", maxChars, ) } // 0. Cache agent's context window on the session (first run only). // Enables scheduler's adaptive throttle to use the real value instead of hardcoded 200K. if l.sessions.GetContextWindow(req.SessionKey) <= 0 { l.sessions.SetContextWindow(req.SessionKey, l.contextWindow) } // 1. Build messages from session history history := l.sessions.GetHistory(req.SessionKey) summary := l.sessions.GetSummary(req.SessionKey) // buildMessages resolves context files once and also detects BOOTSTRAP.md presence // (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection. messages, hadBootstrap := l.buildMessages(ctx, history, summary, req.Message, req.ExtraSystemPrompt, req.SessionKey, req.Channel, req.UserID, req.HistoryLimit, req.SkillFilter) // 2. Attach vision images to the current user message (last in messages slice). // Images are only attached to the live request, NOT persisted in session history. if len(req.Media) > 0 { if images := loadImages(req.Media); len(images) > 0 { messages[len(messages)-1].Images = images ctx = tools.WithMediaImages(ctx, images) // make images available to read_image tool slog.Info("vision: attached images to user message", "count", len(images), "agent", l.id, "session", req.SessionKey) } // Clean up temp media files — they're now base64-encoded in memory. for _, p := range req.Media { if err := os.Remove(p); err != nil { slog.Debug("vision: failed to clean temp media file", "path", p, "error", err) } } } // 3. Buffer new messages — write to session only AFTER the run completes. // This prevents concurrent runs from seeing each other's in-progress messages. // NOTE: pendingMsgs stores TEXT ONLY (no images) to avoid bloating session storage. var pendingMsgs []providers.Message pendingMsgs = append(pendingMsgs, providers.Message{ Role: "user", Content: req.Message, }) // 4. Run LLM iteration loop var loopDetector toolLoopState // detects repeated no-progress tool calls var totalUsage providers.Usage iteration := 0 var finalContent string var asyncToolCalls []string // track async spawn tool names for fallback var mediaResults []MediaResult // media files from tool MEDIA: results var deliverables []string // actual content from tool outputs (for team task results) // Mid-loop compaction: summarize in-memory messages when context exceeds threshold. // Uses same config as maybeSummarize (contextWindow * historyShare). var midLoopCompacted bool // Team task orphan detection: track team_tasks create vs spawn calls. // If the LLM creates tasks but forgets to spawn, inject a reminder. var teamTaskCreates int // count of team_tasks action=create calls var teamTaskSpawns int // count of spawn calls with team_task_id var teamTaskRetried bool // only retry once to prevent infinite loops // Inject retry hook so channels can update placeholder on LLM retries. ctx = providers.WithRetryHook(ctx, func(attempt, maxAttempts int, err error) { l.emit(AgentEvent{ Type: protocol.AgentEventRunRetrying, AgentID: l.id, RunID: req.RunID, Payload: map[string]string{ "attempt": fmt.Sprintf("%d", attempt), "maxAttempts": fmt.Sprintf("%d", maxAttempts), "error": err.Error(), }, }) }) maxIter := l.maxIterations if req.MaxIterations > 0 && req.MaxIterations < maxIter { maxIter = req.MaxIterations } for iteration < maxIter { iteration++ slog.Debug("agent iteration", "agent", l.id, "iteration", iteration, "messages", len(messages)) // Build provider request with policy-filtered tools var toolDefs []providers.ToolDefinition var allowedTools map[string]bool if l.toolPolicy != nil { toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), l.agentToolPolicy, req.ToolAllow, false, false) allowedTools = make(map[string]bool, len(toolDefs)) for _, td := range toolDefs { allowedTools[td.Function.Name] = true } } else { toolDefs = l.tools.ProviderDefs() } chatReq := providers.ChatRequest{ Messages: messages, Tools: toolDefs, Model: l.model, Options: map[string]interface{}{ providers.OptMaxTokens: 8192, providers.OptTemperature: 0.7, }, } if l.thinkingLevel != "" && l.thinkingLevel != "off" { if tc, ok := l.provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { chatReq.Options[providers.OptThinkingLevel] = l.thinkingLevel } else { slog.Debug("thinking_level ignored: provider does not support thinking", "provider", l.provider.Name(), "level", l.thinkingLevel) } } // Call LLM (streaming or non-streaming) var resp *providers.ChatResponse var err error llmSpanStart := time.Now().UTC() if req.Stream { resp, err = l.provider.ChatStream(ctx, chatReq, func(chunk providers.StreamChunk) { if chunk.Thinking != "" { l.emit(AgentEvent{ Type: protocol.ChatEventThinking, AgentID: l.id, RunID: req.RunID, Payload: map[string]string{"content": chunk.Thinking}, }) } if chunk.Content != "" { l.emit(AgentEvent{ Type: protocol.ChatEventChunk, AgentID: l.id, RunID: req.RunID, Payload: map[string]string{"content": chunk.Content}, }) } }) } else { resp, err = l.provider.Chat(ctx, chatReq) } if err != nil { l.emitLLMSpan(ctx, llmSpanStart, iteration, messages, nil, err) return nil, fmt.Errorf("LLM call failed (iteration %d): %w", iteration, err) } l.emitLLMSpan(ctx, llmSpanStart, iteration, messages, resp, nil) if resp.Usage != nil { totalUsage.PromptTokens += resp.Usage.PromptTokens totalUsage.CompletionTokens += resp.Usage.CompletionTokens totalUsage.TotalTokens += resp.Usage.TotalTokens totalUsage.ThinkingTokens += resp.Usage.ThinkingTokens } // Mid-loop compaction: same threshold as maybeSummarize (contextWindow * historyShare) // but applied to in-memory messages during the run. Prevents context overflow for // long-running agents (e.g. delegated research tasks that accumulate many tool results). if !midLoopCompacted && l.contextWindow > 0 { historyShare := 0.75 if l.compactionCfg != nil && l.compactionCfg.MaxHistoryShare > 0 { historyShare = l.compactionCfg.MaxHistoryShare } threshold := int(float64(l.contextWindow) * historyShare) promptTokens := 0 if resp.Usage != nil && resp.Usage.PromptTokens > 0 { promptTokens = resp.Usage.PromptTokens } else { promptTokens = EstimateTokens(messages) } if promptTokens >= threshold { midLoopCompacted = true if compacted := l.compactMessagesInPlace(ctx, messages); compacted != nil { messages = compacted } slog.Info("mid_loop_compaction", "agent", l.id, "prompt_tokens", promptTokens, "threshold", threshold, "context_window", l.contextWindow) } } // No tool calls → done if len(resp.ToolCalls) == 0 { // Guard: detect orphaned team_tasks create (created but not spawned). // The LLM sometimes "forgets" step 2 (spawn) after creating a task. // Inject a reminder and retry once so the task actually executes. if teamTaskCreates > teamTaskSpawns && !teamTaskRetried { teamTaskRetried = true orphaned := teamTaskCreates - teamTaskSpawns slog.Warn("team task orphan detected: created without spawn", "agent", l.id, "orphaned", orphaned, "creates", teamTaskCreates, "spawns", teamTaskSpawns) messages = append(messages, providers.Message{Role: "assistant", Content: resp.Content}, providers.Message{ Role: "user", Content: fmt.Sprintf("[System] You created %d team task(s) but only spawned %d. Tasks without `spawn` will stay pending forever and never execute. Call `spawn` now for each pending task.", teamTaskCreates, teamTaskSpawns), }, ) continue } finalContent = resp.Content break } // Build assistant message with tool calls assistantMsg := providers.Message{ Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls, RawAssistantContent: resp.RawAssistantContent, // preserve thinking blocks for Anthropic passback } messages = append(messages, assistantMsg) pendingMsgs = append(pendingMsgs, assistantMsg) // Track team_tasks create for orphan detection (argument-based, pre-execution). // Spawn counting is done post-execution so failed spawns don't get counted. for _, tc := range resp.ToolCalls { if tc.Name == "team_tasks" { if action, _ := tc.Arguments["action"].(string); action == "create" { teamTaskCreates++ } } } // Execute tool calls (parallel when multiple, sequential when single) if len(resp.ToolCalls) == 1 { // Single tool: sequential — no goroutine overhead tc := resp.ToolCalls[0] l.emit(AgentEvent{ Type: protocol.AgentEventToolCall, AgentID: l.id, RunID: req.RunID, Payload: map[string]interface{}{"name": tc.Name, "id": tc.ID}, }) argsJSON, _ := json.Marshal(tc.Arguments) slog.Info("tool call", "agent", l.id, "tool", tc.Name, "args_len", len(argsJSON)) argsHash := loopDetector.record(tc.Name, tc.Arguments) toolSpanStart := time.Now().UTC() var result *tools.Result if allowedTools != nil && !allowedTools[tc.Name] { slog.Warn("security.tool_policy_blocked", "agent", l.id, "tool", tc.Name) result = tools.ErrorResult("tool not allowed by policy: " + tc.Name) } else { result = l.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) } l.emitToolSpan(ctx, toolSpanStart, tc.Name, tc.ID, string(argsJSON), result) // Record result for loop detection. loopDetector.recordResult(argsHash, result.ForLLM) if result.Async { asyncToolCalls = append(asyncToolCalls, tc.Name) } if result.IsError { errMsg := result.ForLLM if len(errMsg) > 200 { errMsg = errMsg[:200] + "..." } slog.Warn("tool error", "agent", l.id, "tool", tc.Name, "error", errMsg) } // Count successful spawn calls for orphan detection (post-execution). if tc.Name == "spawn" && !result.IsError { if tid, _ := tc.Arguments["team_task_id"].(string); tid != "" { teamTaskSpawns++ } } l.emit(AgentEvent{ Type: protocol.AgentEventToolResult, AgentID: l.id, RunID: req.RunID, Payload: map[string]interface{}{ "name": tc.Name, "id": tc.ID, "is_error": result.IsError, }, }) // Collect MEDIA: paths from tool results if mr := parseMediaResult(result.ForLLM); mr != nil { mediaResults = append(mediaResults, *mr) } for _, p := range result.Media { mediaResults = append(mediaResults, MediaResult{Path: p, ContentType: mimeFromExt(filepath.Ext(p))}) } if result.Deliverable != "" { deliverables = append(deliverables, result.Deliverable) } toolMsg := providers.Message{ Role: "tool", Content: result.ForLLM, ToolCallID: tc.ID, } messages = append(messages, toolMsg) pendingMsgs = append(pendingMsgs, toolMsg) // Check for tool call loop after recording result. if level, msg := loopDetector.detect(tc.Name, argsHash); level != "" { if level == "critical" { slog.Warn("tool loop critical", "agent", l.id, "tool", tc.Name, "message", msg) finalContent = "I was unable to complete this task — I got stuck repeatedly calling " + tc.Name + " without making progress. Please try rephrasing your request." break } // Warning: inject message so model knows to change strategy. slog.Warn("tool loop warning", "agent", l.id, "tool", tc.Name, "message", msg) messages = append(messages, providers.Message{Role: "user", Content: msg}) } } else { // Multiple tools: parallel execution via goroutines. // Tool instances are immutable (context-based) so concurrent access is safe. // Results are collected then processed sequentially for deterministic ordering. type indexedResult struct { idx int tc providers.ToolCall result *tools.Result argsJSON string spanStart time.Time } // 1. Emit all tool.call events upfront (client sees all calls starting) for _, tc := range resp.ToolCalls { l.emit(AgentEvent{ Type: protocol.AgentEventToolCall, AgentID: l.id, RunID: req.RunID, Payload: map[string]interface{}{"name": tc.Name, "id": tc.ID}, }) } // 2. Execute all tools in parallel resultCh := make(chan indexedResult, len(resp.ToolCalls)) var wg sync.WaitGroup for i, tc := range resp.ToolCalls { wg.Add(1) go func(idx int, tc providers.ToolCall) { defer wg.Done() argsJSON, _ := json.Marshal(tc.Arguments) slog.Info("tool call", "agent", l.id, "tool", tc.Name, "args_len", len(argsJSON), "parallel", true) spanStart := time.Now().UTC() var result *tools.Result if allowedTools != nil && !allowedTools[tc.Name] { slog.Warn("security.tool_policy_blocked", "agent", l.id, "tool", tc.Name) result = tools.ErrorResult("tool not allowed by policy: " + tc.Name) } else { result = l.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) } resultCh <- indexedResult{idx: idx, tc: tc, result: result, argsJSON: string(argsJSON), spanStart: spanStart} }(i, tc) } // Close channel after all goroutines complete (run in separate goroutine to avoid deadlock) go func() { wg.Wait(); close(resultCh) }() // 3. Collect results collected := make([]indexedResult, 0, len(resp.ToolCalls)) for r := range resultCh { collected = append(collected, r) } // 4. Sort by original index → deterministic message ordering sort.Slice(collected, func(i, j int) bool { return collected[i].idx < collected[j].idx }) // 5. Process results sequentially: emit events, append messages, save to session var loopStuck bool for _, r := range collected { l.emitToolSpan(ctx, r.spanStart, r.tc.Name, r.tc.ID, r.argsJSON, r.result) // Record for loop detection. argsHash := loopDetector.record(r.tc.Name, r.tc.Arguments) loopDetector.recordResult(argsHash, r.result.ForLLM) if r.result.Async { asyncToolCalls = append(asyncToolCalls, r.tc.Name) } if r.result.IsError { errMsg := r.result.ForLLM if len(errMsg) > 200 { errMsg = errMsg[:200] + "..." } slog.Warn("tool error", "agent", l.id, "tool", r.tc.Name, "error", errMsg) } // Count successful spawn calls for orphan detection (post-execution). if r.tc.Name == "spawn" && !r.result.IsError { if tid, _ := r.tc.Arguments["team_task_id"].(string); tid != "" { teamTaskSpawns++ } } l.emit(AgentEvent{ Type: protocol.AgentEventToolResult, AgentID: l.id, RunID: req.RunID, Payload: map[string]interface{}{ "name": r.tc.Name, "id": r.tc.ID, "is_error": r.result.IsError, }, }) // Collect MEDIA: paths from tool results if mr := parseMediaResult(r.result.ForLLM); mr != nil { mediaResults = append(mediaResults, *mr) } for _, p := range r.result.Media { mediaResults = append(mediaResults, MediaResult{Path: p, ContentType: mimeFromExt(filepath.Ext(p))}) } if r.result.Deliverable != "" { deliverables = append(deliverables, r.result.Deliverable) } toolMsg := providers.Message{ Role: "tool", Content: r.result.ForLLM, ToolCallID: r.tc.ID, } messages = append(messages, toolMsg) pendingMsgs = append(pendingMsgs, toolMsg) // Check for tool call loop. if level, msg := loopDetector.detect(r.tc.Name, argsHash); level != "" { if level == "critical" { slog.Warn("tool loop critical", "agent", l.id, "tool", r.tc.Name, "message", msg) finalContent = "I was unable to complete this task — I got stuck repeatedly calling " + r.tc.Name + " without making progress. Please try rephrasing your request." loopStuck = true break } slog.Warn("tool loop warning", "agent", l.id, "tool", r.tc.Name, "message", msg) messages = append(messages, providers.Message{Role: "user", Content: msg}) } } if loopStuck { break } } } // 4. Full sanitization pipeline (matching TS extractAssistantText + sanitizeUserFacingText) finalContent = SanitizeAssistantContent(finalContent) // 5. Handle NO_REPLY: save to session for context but mark as silent. // Matching TS: NO_REPLY is saved (via resolveSilentReplyFallbackText) but // filtered at the payload level before delivery. isSilent := IsSilentReply(finalContent) // 6. Fallback for empty content if finalContent == "" { if len(asyncToolCalls) > 0 { finalContent = "..." } else { finalContent = "..." } } pendingMsgs = append(pendingMsgs, providers.Message{ Role: "assistant", Content: finalContent, }) // Flush all buffered messages to session atomically. // This ensures concurrent runs never see each other's in-progress messages. for _, msg := range pendingMsgs { l.sessions.AddMessage(req.SessionKey, msg) } // Write session metadata (matching TS session entry updates) l.sessions.UpdateMetadata(req.SessionKey, l.model, l.provider.Name(), req.Channel) l.sessions.AccumulateTokens(req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens)) // Calibrate token estimation: store actual prompt tokens + message count. // Next time EstimateTokensWithCalibration() is called, it uses this as a base // instead of the chars/3 heuristic (more accurate for multilingual content). if totalUsage.PromptTokens > 0 { msgCount := len(history) + len(pendingMsgs) l.sessions.SetLastPromptTokens(req.SessionKey, totalUsage.PromptTokens, msgCount) } l.sessions.Save(req.SessionKey) // Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md // as a safety net in case the LLM didn't clear it itself. // Bootstrap typically completes in 2-3 turns; we auto-cleanup after 3 user messages. // Uses pre-run history (already loaded) + 1 for current message — no extra DB call. if hadBootstrap && l.bootstrapCleanup != nil { userTurns := 1 // current user message for _, m := range history { if m.Role == "user" { userTurns++ } } if userTurns >= bootstrapAutoCleanupTurns { if cleanErr := l.bootstrapCleanup(ctx, l.agentUUID, req.UserID); cleanErr != nil { slog.Warn("bootstrap auto-cleanup failed", "error", cleanErr, "agent", l.id, "user", req.UserID) } else { slog.Info("bootstrap auto-cleanup completed", "agent", l.id, "user", req.UserID, "turns", userTurns) } } } // If silent, return empty content so gateway suppresses delivery. if isSilent { slog.Info("agent loop: NO_REPLY detected, suppressing delivery", "agent", l.id, "session", req.SessionKey) finalContent = "" } // 5. Maybe summarize l.maybeSummarize(ctx, req.SessionKey) // Include forwarded media from delegation results (not cleaned up like req.Media) for _, p := range req.ForwardMedia { mediaResults = append(mediaResults, MediaResult{Path: p, ContentType: mimeFromExt(filepath.Ext(p))}) } return &RunResult{ Content: finalContent, RunID: req.RunID, Iterations: iteration, Usage: &totalUsage, Media: mediaResults, Deliverables: deliverables, }, nil } // compactMessagesInPlace summarizes the first ~70% of messages into a condensed // summary, keeping the last ~30% intact. Operates purely on the local messages // slice — no session state touched, no locks needed. // Returns nil on failure (caller keeps original messages). func (l *Loop) compactMessagesInPlace(ctx context.Context, messages []providers.Message) []providers.Message { if len(messages) < 6 { return nil } // Resolve keepCount from compaction config (same defaults as maybeSummarize). keepCount := 4 if l.compactionCfg != nil && l.compactionCfg.KeepLastMessages > 0 { keepCount = l.compactionCfg.KeepLastMessages } // Ensure we keep at least 30% of messages. if minKeep := len(messages) * 3 / 10; minKeep > keepCount { keepCount = minKeep } splitIdx := len(messages) - keepCount // Walk backward from splitIdx to find a clean boundary — // avoid splitting tool_use → tool_result pairs. for splitIdx > 0 { m := messages[splitIdx] if m.Role == "tool" || (m.Role == "assistant" && len(m.ToolCalls) > 0) { splitIdx-- continue } break } if splitIdx <= 1 { return nil } // Build summary input (same pattern as maybeSummarize in loop_history.go). toSummarize := messages[:splitIdx] var sb strings.Builder for _, m := range toSummarize { switch m.Role { case "user": fmt.Fprintf(&sb, "user: %s\n", m.Content) case "assistant": fmt.Fprintf(&sb, "assistant: %s\n", SanitizeAssistantContent(m.Content)) } } sctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() resp, err := l.provider.Chat(sctx, providers.ChatRequest{ Messages: []providers.Message{{ Role: "user", Content: "Provide a concise summary of this conversation, preserving key findings, data, and context:\n\n" + sb.String(), }}, Model: l.model, Options: map[string]interface{}{"max_tokens": 1024, "temperature": 0.3}, }) if err != nil { slog.Warn("mid_loop_compaction_failed", "agent", l.id, "error", err) return nil } summary := providers.Message{ Role: "user", Content: "[Summary of earlier conversation]\n" + SanitizeAssistantContent(resp.Content), } result := make([]providers.Message, 0, 1+keepCount) result = append(result, summary) result = append(result, messages[splitIdx:]...) slog.Info("mid_loop_compacted", "agent", l.id, "original_msgs", len(messages), "summarized", splitIdx, "kept", len(result)) return result } // parseMediaResult extracts a MediaResult from a tool result string containing "MEDIA:" prefix. // Handles formats: "MEDIA:/path/to/file" and "[[audio_as_voice]]\nMEDIA:/path/to/file". // Returns nil if no MEDIA: prefix is found. func parseMediaResult(toolOutput string) *MediaResult { s := toolOutput asVoice := false // Check for [[audio_as_voice]] tag (TTS voice messages) if strings.Contains(s, "[[audio_as_voice]]") { asVoice = true s = strings.ReplaceAll(s, "[[audio_as_voice]]", "") s = strings.TrimSpace(s) } // Find MEDIA: prefix idx := strings.Index(s, "MEDIA:") if idx < 0 { return nil } path := strings.TrimSpace(s[idx+6:]) if path == "" { return nil } // Take only the first line (in case there's trailing text) if nl := strings.IndexByte(path, '\n'); nl >= 0 { path = strings.TrimSpace(path[:nl]) } return &MediaResult{ Path: path, ContentType: mimeFromExt(filepath.Ext(path)), AsVoice: asVoice, } } // mimeFromExt returns a MIME type for common media file extensions. func mimeFromExt(ext string) string { switch strings.ToLower(ext) { case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".gif": return "image/gif" case ".webp": return "image/webp" case ".mp4": return "video/mp4" case ".ogg", ".opus": return "audio/ogg" case ".mp3": return "audio/mpeg" case ".wav": return "audio/wav" case ".txt": return "text/plain" case ".pdf": return "application/pdf" case ".csv": return "text/csv" case ".json": return "application/json" case ".html", ".htm": return "text/html" case ".xml": return "application/xml" case ".zip": return "application/zip" case ".doc", ".docx": return "application/msword" case ".xls", ".xlsx": return "application/vnd.ms-excel" default: return "application/octet-stream" } } // sanitizePathSegment makes a userID safe for use as a directory name. // Replaces colons, spaces, and other unsafe chars with underscores. func sanitizePathSegment(s string) string { var b strings.Builder for _, r := range s { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { b.WriteRune(r) } else { b.WriteByte('_') } } return b.String() } // InvalidateUserWorkspace clears the cached workspace for a user, // forcing the next request to re-read from user_agent_profiles. func (l *Loop) InvalidateUserWorkspace(userID string) { l.userWorkspaces.Delete(userID) }