// PicoClaw - Ultra-lightweight personal AI agent package agent import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/utils" ) func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, error) { turnCtx, turnCancel := context.WithCancel(ctx) defer turnCancel() ts.setTurnCancel(turnCancel) // Inject turnState and AgentLoop into context so tools (e.g. spawn) can retrieve them. turnCtx = withTurnState(turnCtx, ts) turnCtx = WithAgentLoop(turnCtx, al) al.registerActiveTurn(ts) defer al.clearActiveTurn(ts) turnStatus := TurnEndStatusCompleted defer func() { al.emitEvent( EventKindTurnEnd, ts.eventMeta("runTurn", "turn.end"), TurnEndPayload{ Status: turnStatus, Iterations: ts.currentIteration(), Duration: time.Since(ts.startedAt), FinalContentLen: ts.finalContentLen(), }, ) }() al.emitEvent( EventKindTurnStart, ts.eventMeta("runTurn", "turn.start"), TurnStartPayload{ UserMessage: ts.userMessage, MediaCount: len(ts.media), }, ) var history []providers.Message var summary string if !ts.opts.NoHistory { // ContextManager assembles budget-aware history and summary. if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ SessionKey: ts.sessionKey, Budget: ts.agent.ContextWindow, MaxTokens: ts.agent.MaxTokens, }); err == nil && resp != nil { history = resp.History summary = resp.Summary } } ts.captureRestorePoint(history, summary) messages := ts.agent.ContextBuilder.BuildMessages( history, summary, ts.userMessage, ts.media, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, activeSkillNames(ts.agent, ts.opts)..., ) cfg := al.GetConfig() maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) if !ts.opts.NoHistory { toolDefs := ts.agent.Tools.ToProviderDefs() if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", map[string]any{"session_key": ts.sessionKey}) if err := al.contextManager.Compact(turnCtx, &CompactRequest{ SessionKey: ts.sessionKey, Reason: ContextCompressReasonProactive, Budget: ts.agent.ContextWindow, }); err != nil { logger.WarnCF("agent", "Proactive compact failed", map[string]any{ "session_key": ts.sessionKey, "error": err.Error(), }) } ts.refreshRestorePointFromSession(ts.agent) // Re-assemble from CM after compact. if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ SessionKey: ts.sessionKey, Budget: ts.agent.ContextWindow, MaxTokens: ts.agent.MaxTokens, }); err == nil && resp != nil { history = resp.History summary = resp.Summary } messages = ts.agent.ContextBuilder.BuildMessages( history, summary, ts.userMessage, ts.media, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, activeSkillNames(ts.agent, ts.opts)..., ) messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) } } // Save user message to session (from Incoming) if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { rootMsg := providers.Message{ Role: "user", Content: ts.userMessage, Media: append([]string(nil), ts.media...), } if len(rootMsg.Media) > 0 { ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) } else { ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) } ts.recordPersistedMessage(rootMsg) ts.ingestMessage(turnCtx, al, rootMsg) } activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages) activeProvider := ts.agent.Provider if usedLight && ts.agent.LightProvider != nil { activeProvider = ts.agent.LightProvider } pendingMessages := append([]providers.Message(nil), ts.opts.InitialSteeringMessages...) var finalContent string turnLoop: for ts.currentIteration() < ts.agent.MaxIterations || len(pendingMessages) > 0 || func() bool { graceful, _ := ts.gracefulInterruptRequested() return graceful }() { if ts.hardAbortRequested() { turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } iteration := ts.currentIteration() + 1 ts.setIteration(iteration) ts.setPhase(TurnPhaseRunning) if iteration > 1 { if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { pendingMessages = append(pendingMessages, steerMsgs...) } } else if !ts.opts.SkipInitialSteeringPoll { if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 { pendingMessages = append(pendingMessages, steerMsgs...) } } // Check if parent turn has ended (SubTurn support from HEAD) if ts.parentTurnState != nil && ts.IsParentEnded() { if !ts.critical { logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{ "agent_id": ts.agentID, "iteration": iteration, "turn_id": ts.turnID, }) break } logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{ "agent_id": ts.agentID, "iteration": iteration, "turn_id": ts.turnID, }) } // Poll for pending SubTurn results (from HEAD) if ts.pendingResults != nil { select { case result, ok := <-ts.pendingResults: if ok && result != nil && result.ForLLM != "" { content := al.cfg.FilterSensitiveData(result.ForLLM) msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} pendingMessages = append(pendingMessages, msg) } default: // No results available } } // Inject pending steering messages if len(pendingMessages) > 0 { resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize) totalContentLen := 0 for i, pm := range pendingMessages { messages = append(messages, resolvedPending[i]) totalContentLen += len(pm.Content) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm) ts.recordPersistedMessage(pm) ts.ingestMessage(turnCtx, al, pm) } logger.InfoCF("agent", "Injected steering message into context", map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, "content_len": len(pm.Content), "media_count": len(pm.Media), }) } al.emitEvent( EventKindSteeringInjected, ts.eventMeta("runTurn", "turn.steering.injected"), SteeringInjectedPayload{ Count: len(pendingMessages), TotalContentLen: totalContentLen, }, ) pendingMessages = nil } logger.DebugCF("agent", "LLM iteration", map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, "max": ts.agent.MaxIterations, }) gracefulTerminal, _ := ts.gracefulInterruptRequested() providerToolDefs := ts.agent.Tools.ToProviderDefs() // Native web search support (from HEAD) _, hasWebSearch := ts.agent.Tools.Get("web_search") useNativeSearch := al.cfg.Tools.Web.PreferNative && hasWebSearch && func() bool { // Check if provider supports native search if ns, ok := ts.agent.Provider.(interface{ SupportsNativeSearch() bool }); ok { return ns.SupportsNativeSearch() } return false }() if useNativeSearch { // Filter out client-side web_search tool filtered := make([]providers.ToolDefinition, 0, len(providerToolDefs)) for _, td := range providerToolDefs { if td.Function.Name != "web_search" { filtered = append(filtered, td) } } providerToolDefs = filtered } // Resolve media:// refs produced by tool results (e.g. load_image). // Skipped on iteration 1 because inbound user media is already resolved // before entering the loop; only subsequent iterations can contain new // tool-generated media refs that need base64 encoding. if iteration > 1 { messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) } callMessages := messages if gracefulTerminal { callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) providerToolDefs = nil ts.markGracefulTerminalUsed() } llmOpts := map[string]any{ "max_tokens": ts.agent.MaxTokens, "temperature": ts.agent.Temperature, "prompt_cache_key": ts.agent.ID, } if useNativeSearch { llmOpts["native_search"] = true } if ts.agent.ThinkingLevel != ThinkingOff { if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel) } else { logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)}) } } llmModel := activeModel if al.hooks != nil { llmReq, decision := al.hooks.BeforeLLM(turnCtx, &LLMHookRequest{ Meta: ts.eventMeta("runTurn", "turn.llm.request"), Context: cloneTurnContext(ts.turnCtx), Model: llmModel, Messages: callMessages, Tools: providerToolDefs, Options: llmOpts, GracefulTerminal: gracefulTerminal, }) switch decision.normalizedAction() { case HookActionContinue, HookActionModify: if llmReq != nil { llmModel = llmReq.Model callMessages = llmReq.Messages providerToolDefs = llmReq.Tools llmOpts = llmReq.Options } case HookActionAbortTurn: turnStatus = TurnEndStatusError return turnResult{}, al.hookAbortError(ts, "before_llm", decision) case HookActionHardAbort: _ = ts.requestHardAbort() turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } } al.emitEvent( EventKindLLMRequest, ts.eventMeta("runTurn", "turn.llm.request"), LLMRequestPayload{ Model: llmModel, MessagesCount: len(callMessages), ToolsCount: len(providerToolDefs), MaxTokens: ts.agent.MaxTokens, Temperature: ts.agent.Temperature, }, ) logger.DebugCF("agent", "LLM request", map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, "model": llmModel, "messages_count": len(callMessages), "tools_count": len(providerToolDefs), "max_tokens": ts.agent.MaxTokens, "temperature": ts.agent.Temperature, "system_prompt_len": len(callMessages[0].Content), }) logger.DebugCF("agent", "Full LLM request", map[string]any{ "iteration": iteration, "messages_json": formatMessagesForLog(callMessages), "tools_json": formatToolsForLog(providerToolDefs), }) callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) { providerCtx, providerCancel := context.WithCancel(turnCtx) ts.setProviderCancel(providerCancel) defer func() { providerCancel() ts.clearProviderCancel(providerCancel) }() al.activeRequests.Add(1) defer al.activeRequests.Done() if len(activeCandidates) > 1 && al.fallback != nil { fbResult, fbErr := al.fallback.Execute( providerCtx, activeCandidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { candidateProvider := activeProvider if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok { candidateProvider = cp } return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, llmOpts) }, ) 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": ts.agent.ID, "iteration": iteration}, ) } return fbResult.Response, nil } return activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, llmModel, llmOpts) } var response *providers.LLMResponse var err error maxRetries := 2 for retry := 0; retry <= maxRetries; retry++ { response, err = callLLM(callMessages, providerToolDefs) if err == nil { break } if ts.hardAbortRequested() && errors.Is(err, context.Canceled) { turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } // Retry without media if vision is unsupported if hasMediaRefs(callMessages) && isVisionUnsupportedError(err) && retry < maxRetries { al.emitEvent( EventKindLLMRetry, ts.eventMeta("runTurn", "turn.llm.retry"), LLMRetryPayload{ Attempt: retry + 1, MaxRetries: maxRetries, Reason: "vision_unsupported", Error: err.Error(), Backoff: 0, }, ) logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{ "error": err.Error(), "retry": retry, }) callMessages = stripMessageMedia(callMessages) // Also strip media from session history to prevent future errors if !ts.opts.NoHistory { history = stripMessageMedia(history) ts.agent.Sessions.SetHistory(ts.sessionKey, history) for i := range ts.persistedMessages { ts.persistedMessages[i].Media = nil } ts.refreshRestorePointFromSession(ts.agent) } continue } 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, "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 al.emitEvent( EventKindLLMRetry, ts.eventMeta("runTurn", "turn.llm.retry"), LLMRetryPayload{ Attempt: retry + 1, MaxRetries: maxRetries, Reason: "timeout", Error: err.Error(), Backoff: backoff, }, ) logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ "error": err.Error(), "retry": retry, "backoff": backoff.String(), }) if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil { if ts.hardAbortRequested() { turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } err = sleepErr break } continue } if isContextError && retry < maxRetries && !ts.opts.NoHistory { al.emitEvent( EventKindLLMRetry, ts.eventMeta("runTurn", "turn.llm.retry"), LLMRetryPayload{ Attempt: retry + 1, MaxRetries: maxRetries, Reason: "context_limit", Error: err.Error(), }, ) logger.WarnCF( "agent", "Context window error detected, attempting compression", map[string]any{ "error": err.Error(), "retry": retry, }, ) if retry == 0 && !constants.IsInternalChannel(ts.channel) { al.bus.PublishOutbound(ctx, outboundMessageForTurn( ts, "Context window exceeded. Compressing history and retrying...", )) } if compactErr := al.contextManager.Compact(turnCtx, &CompactRequest{ SessionKey: ts.sessionKey, Reason: ContextCompressReasonRetry, Budget: ts.agent.ContextWindow, }); compactErr != nil { logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ "session_key": ts.sessionKey, "error": compactErr.Error(), }) } ts.refreshRestorePointFromSession(ts.agent) // Re-assemble from CM after compact. if asmResp, asmErr := al.contextManager.Assemble(turnCtx, &AssembleRequest{ SessionKey: ts.sessionKey, Budget: ts.agent.ContextWindow, MaxTokens: ts.agent.MaxTokens, }); asmErr == nil && asmResp != nil { history = asmResp.History summary = asmResp.Summary } messages = ts.agent.ContextBuilder.BuildMessages( history, summary, "", nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, activeSkillNames(ts.agent, ts.opts)..., ) callMessages = messages if gracefulTerminal { callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) } continue } break } if err != nil { turnStatus = TurnEndStatusError al.emitEvent( EventKindError, ts.eventMeta("runTurn", "turn.error"), ErrorPayload{ Stage: "llm", Message: err.Error(), }, ) logger.ErrorCF("agent", "LLM call failed", map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, "model": llmModel, "error": err.Error(), }) return turnResult{}, fmt.Errorf("LLM call failed after retries: %w", err) } if al.hooks != nil { llmResp, decision := al.hooks.AfterLLM(turnCtx, &LLMHookResponse{ Meta: ts.eventMeta("runTurn", "turn.llm.response"), Context: cloneTurnContext(ts.turnCtx), Model: llmModel, Response: response, }) switch decision.normalizedAction() { case HookActionContinue, HookActionModify: if llmResp != nil && llmResp.Response != nil { response = llmResp.Response } case HookActionAbortTurn: turnStatus = TurnEndStatusError return turnResult{}, al.hookAbortError(ts, "after_llm", decision) case HookActionHardAbort: _ = ts.requestHardAbort() turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } } // Save finishReason to turnState for SubTurn truncation detection if innerTS := turnStateFromContext(ctx); innerTS != nil { innerTS.SetLastFinishReason(response.FinishReason) // Save usage for token budget tracking if response.Usage != nil { innerTS.SetLastUsage(response.Usage) } } reasoningContent := response.Reasoning if reasoningContent == "" { reasoningContent = response.ReasoningContent } if ts.channel == "pico" { go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) } else { go al.handleReasoning( turnCtx, reasoningContent, ts.channel, al.targetReasoningChannelID(ts.channel), ) } al.emitEvent( EventKindLLMResponse, ts.eventMeta("runTurn", "turn.llm.response"), LLMResponsePayload{ ContentLen: len(response.Content), ToolCalls: len(response.ToolCalls), HasReasoning: response.Reasoning != "" || response.ReasoningContent != "", }, ) llmResponseFields := map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, "content_chars": len(response.Content), "tool_calls": len(response.ToolCalls), "reasoning": response.Reasoning, "target_channel": al.targetReasoningChannelID(ts.channel), "channel": ts.channel, } if response.Usage != nil { llmResponseFields["prompt_tokens"] = response.Usage.PromptTokens llmResponseFields["completion_tokens"] = response.Usage.CompletionTokens llmResponseFields["total_tokens"] = response.Usage.TotalTokens } logger.DebugCF("agent", "LLM response", llmResponseFields) if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish { if strings.TrimSpace(response.Content) != "" { outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) err := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ Channel: ts.channel, ChatID: ts.chatID, Content: response.Content, }) outCancel() if err != nil { logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{ "error": err.Error(), "channel": ts.channel, "chat_id": ts.chatID, "iteration": iteration, }) } } } if len(response.ToolCalls) == 0 || gracefulTerminal { responseContent := response.Content if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" { responseContent = response.ReasoningContent } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn", map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, "steering_count": len(steerMsgs), }) pendingMessages = append(pendingMessages, steerMsgs...) continue } finalContent = responseContent logger.InfoCF("agent", "LLM response without tool calls (direct answer)", map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, "content_chars": len(finalContent), }) break } normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) for _, tc := range response.ToolCalls { normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) } toolNames := make([]string, 0, len(normalizedToolCalls)) for _, tc := range normalizedToolCalls { toolNames = append(toolNames, tc.Name) } logger.InfoCF("agent", "LLM requested tool calls", map[string]any{ "agent_id": ts.agent.ID, "tools": toolNames, "count": len(normalizedToolCalls), "iteration": iteration, }) allResponsesHandled := len(normalizedToolCalls) > 0 assistantMsg := providers.Message{ Role: "assistant", Content: response.Content, ReasoningContent: response.ReasoningContent, } for _, tc := range normalizedToolCalls { argumentsJSON, _ := json.Marshal(tc.Arguments) extraContent := tc.ExtraContent thoughtSignature := "" if tc.Function != nil { thoughtSignature = tc.Function.ThoughtSignature } assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ ID: tc.ID, Type: "function", Name: tc.Name, Function: &providers.FunctionCall{ Name: tc.Name, Arguments: string(argumentsJSON), ThoughtSignature: thoughtSignature, }, ExtraContent: extraContent, ThoughtSignature: thoughtSignature, }) } messages = append(messages, assistantMsg) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) ts.recordPersistedMessage(assistantMsg) ts.ingestMessage(turnCtx, al, assistantMsg) } ts.setPhase(TurnPhaseTools) for i, tc := range normalizedToolCalls { if ts.hardAbortRequested() { turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } toolName := tc.Name toolArgs := cloneStringAnyMap(tc.Arguments) if al.hooks != nil { toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ Meta: ts.eventMeta("runTurn", "turn.tool.before"), Context: cloneTurnContext(ts.turnCtx), Tool: toolName, Arguments: toolArgs, }) switch decision.normalizedAction() { case HookActionContinue, HookActionModify: if toolReq != nil { toolName = toolReq.Tool toolArgs = toolReq.Arguments } case HookActionRespond: // Hook returns result directly, skip tool execution. // SECURITY: This bypasses ApproveTool, allowing hooks to respond // for any tool name without approval. This is intentional for // plugin tools but means a before_tool hook can override even // sensitive tools like bash. Hook configuration should be // carefully reviewed to prevent unauthorized tool execution. if toolReq != nil && toolReq.HookResult != nil { hookResult := toolReq.HookResult argsJSON, _ := json.Marshal(toolArgs) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview), map[string]any{ "agent_id": ts.agent.ID, "tool": toolName, "iteration": iteration, }) // Emit ToolExecStart event (same as normal tool execution) al.emitEvent( EventKindToolExecStart, ts.eventMeta("runTurn", "turn.tool.start"), ToolExecStartPayload{ Tool: toolName, Arguments: cloneEventArguments(toolArgs), }, ) // Send tool feedback to chat channel if enabled (same as normal tool execution) if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && ts.channel != "" && !ts.opts.SuppressToolFeedback { argsJSON, _ := json.Marshal(toolArgs) feedbackPreview := utils.Truncate( string(argsJSON), al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ Channel: ts.channel, ChatID: ts.chatID, Content: feedbackMsg, }) fbCancel() } toolDuration := time.Duration(0) // Hook execution time unknown // Send ForUser content to user // For ResponseHandled results, send regardless of SendResponse setting, // same as normal tool execution path. shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" && (ts.opts.SendResponse || hookResult.ResponseHandled) if shouldSendForUser { al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Context: bus.InboundContext{ Channel: ts.channel, ChatID: ts.chatID, Raw: map[string]string{ "is_tool_call": "true", }, }, Content: hookResult.ForUser, }) } // Handle media from hook result (same as normal tool execution) if len(hookResult.Media) > 0 && hookResult.ResponseHandled { parts := make([]bus.MediaPart, 0, len(hookResult.Media)) for _, ref := range hookResult.Media { part := bus.MediaPart{Ref: ref} if al.mediaStore != nil { if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { part.Filename = meta.Filename part.ContentType = meta.ContentType part.Type = inferMediaType(meta.Filename, meta.ContentType) } } parts = append(parts, part) } outboundMedia := bus.OutboundMediaMessage{ Channel: ts.channel, ChatID: ts.chatID, Parts: parts, } if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { logger.WarnCF("agent", "Failed to deliver hook media", map[string]any{ "agent_id": ts.agent.ID, "tool": toolName, "channel": ts.channel, "chat_id": ts.chatID, "error": err.Error(), }) // Same as normal tool execution: notify LLM about delivery failure hookResult.IsError = true hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err) } } else if al.bus != nil { al.bus.PublishOutboundMedia(ctx, outboundMedia) // Same as normal tool execution: bus only queues, media not yet delivered hookResult.ResponseHandled = false } } // Track response handling status (same as normal tool execution) if !hookResult.ResponseHandled { allResponsesHandled = false } // Build tool message contentForLLM := hookResult.ContentForLLM() if al.cfg.Tools.IsFilterSensitiveDataEnabled() { contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) } toolResultMsg := providers.Message{ Role: "tool", Content: contentForLLM, ToolCallID: tc.ID, } // Handle media for LLM vision (same as normal tool execution) if len(hookResult.Media) > 0 && !hookResult.ResponseHandled { hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media) // Recalculate contentForLLM after adding ArtifactTags contentForLLM = hookResult.ContentForLLM() if al.cfg.Tools.IsFilterSensitiveDataEnabled() { contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) } toolResultMsg.Content = contentForLLM toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...) } // Emit ToolExecEnd event (after filtering, same as normal tool execution) al.emitEvent( EventKindToolExecEnd, ts.eventMeta("runTurn", "turn.tool.end"), ToolExecEndPayload{ Tool: toolName, Duration: toolDuration, ForLLMLen: len(contentForLLM), ForUserLen: len(hookResult.ForUser), IsError: hookResult.IsError, Async: hookResult.Async, }, ) messages = append(messages, toolResultMsg) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) ts.recordPersistedMessage(toolResultMsg) ts.ingestMessage(turnCtx, al, toolResultMsg) } // Same as normal tool execution: check for steering/interrupt/SubTurn after each tool if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { pendingMessages = append(pendingMessages, steerMsgs...) } skipReason := "" skipMessage := "" if len(pendingMessages) > 0 { skipReason = "queued user steering message" skipMessage = "Skipped due to queued user message." } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { skipReason = "graceful interrupt requested" skipMessage = "Skipped due to graceful interrupt." } if skipReason != "" { remaining := len(normalizedToolCalls) - i - 1 if remaining > 0 { logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond", map[string]any{ "agent_id": ts.agent.ID, "completed": i + 1, "skipped": remaining, "reason": skipReason, }) for j := i + 1; j < len(normalizedToolCalls); j++ { skippedTC := normalizedToolCalls[j] al.emitEvent( EventKindToolExecSkipped, ts.eventMeta("runTurn", "turn.tool.skipped"), ToolExecSkippedPayload{ Tool: skippedTC.Name, Reason: skipReason, }, ) skippedMsg := providers.Message{ Role: "tool", Content: skipMessage, ToolCallID: skippedTC.ID, } messages = append(messages, skippedMsg) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) ts.recordPersistedMessage(skippedMsg) } } } break } // Also poll for any SubTurn results that arrived during tool execution. if ts.pendingResults != nil { select { case result, ok := <-ts.pendingResults: if ok && result != nil && result.ForLLM != "" { content := al.cfg.FilterSensitiveData(result.ForLLM) msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} messages = append(messages, msg) ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) } default: // No results available } } continue } // If no HookResult, fall back to continue with warning logger.WarnCF("agent", "Hook returned respond action but no HookResult provided", map[string]any{ "agent_id": ts.agent.ID, "tool": toolName, "action": "respond", }) case HookActionDenyTool: allResponsesHandled = false denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) al.emitEvent( EventKindToolExecSkipped, ts.eventMeta("runTurn", "turn.tool.skipped"), ToolExecSkippedPayload{ Tool: toolName, Reason: denyContent, }, ) deniedMsg := providers.Message{ Role: "tool", Content: denyContent, ToolCallID: tc.ID, } messages = append(messages, deniedMsg) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) ts.recordPersistedMessage(deniedMsg) } continue case HookActionAbortTurn: turnStatus = TurnEndStatusError return turnResult{}, al.hookAbortError(ts, "before_tool", decision) case HookActionHardAbort: _ = ts.requestHardAbort() turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } } if al.hooks != nil { approval := al.hooks.ApproveTool(turnCtx, &ToolApprovalRequest{ Meta: ts.eventMeta("runTurn", "turn.tool.approve"), Context: cloneTurnContext(ts.turnCtx), Tool: toolName, Arguments: toolArgs, }) if !approval.Approved { allResponsesHandled = false denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) al.emitEvent( EventKindToolExecSkipped, ts.eventMeta("runTurn", "turn.tool.skipped"), ToolExecSkippedPayload{ Tool: toolName, Reason: denyContent, }, ) deniedMsg := providers.Message{ Role: "tool", Content: denyContent, ToolCallID: tc.ID, } messages = append(messages, deniedMsg) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) ts.recordPersistedMessage(deniedMsg) } continue } } argsJSON, _ := json.Marshal(toolArgs) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), map[string]any{ "agent_id": ts.agent.ID, "tool": toolName, "iteration": iteration, }) al.emitEvent( EventKindToolExecStart, ts.eventMeta("runTurn", "turn.tool.start"), ToolExecStartPayload{ Tool: toolName, Arguments: cloneEventArguments(toolArgs), }, ) // Send tool feedback to chat channel if enabled (from HEAD) if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && ts.channel != "" && !ts.opts.SuppressToolFeedback { feedbackPreview := utils.Truncate( string(argsJSON), al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg)) fbCancel() } toolCallID := tc.ID toolIteration := iteration asyncToolName := toolName asyncCallback := func(_ context.Context, result *tools.ToolResult) { // Send ForUser content directly to the user (immediate feedback), // mirroring the synchronous tool execution path. if !result.Silent && result.ForUser != "" { outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) defer outCancel() _ = al.bus.PublishOutbound(outCtx, outboundMessageForTurn(ts, result.ForUser)) } // Determine content for the agent loop (ForLLM or error). content := result.ContentForLLM() if content == "" { return } // Filter sensitive data before publishing content = al.cfg.FilterSensitiveData(content) logger.InfoCF("agent", "Async tool completed, publishing result", map[string]any{ "tool": asyncToolName, "content_len": len(content), "channel": ts.channel, }) al.emitEvent( EventKindFollowUpQueued, ts.scope.meta(toolIteration, "runTurn", "turn.follow_up.queued"), FollowUpQueuedPayload{ SourceTool: asyncToolName, ContentLen: len(content), }, ) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ Context: bus.InboundContext{ Channel: "system", ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), ChatType: "direct", SenderID: fmt.Sprintf("async:%s", asyncToolName), }, Content: content, }) } toolStart := time.Now() execCtx := tools.WithToolInboundContext( turnCtx, ts.channel, ts.chatID, ts.opts.Dispatch.MessageID(), ts.opts.Dispatch.ReplyToMessageID(), ) execCtx = tools.WithToolSessionContext( execCtx, ts.agent.ID, ts.sessionKey, ts.opts.Dispatch.SessionScope, ) toolResult := ts.agent.Tools.ExecuteWithContext( execCtx, toolName, toolArgs, ts.channel, ts.chatID, asyncCallback, ) toolDuration := time.Since(toolStart) if ts.hardAbortRequested() { turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } if al.hooks != nil { toolResp, decision := al.hooks.AfterTool(turnCtx, &ToolResultHookResponse{ Meta: ts.eventMeta("runTurn", "turn.tool.after"), Context: cloneTurnContext(ts.turnCtx), Tool: toolName, Arguments: toolArgs, Result: toolResult, Duration: toolDuration, }) switch decision.normalizedAction() { case HookActionContinue, HookActionModify: if toolResp != nil { if toolResp.Tool != "" { toolName = toolResp.Tool } if toolResp.Result != nil { toolResult = toolResp.Result } } case HookActionAbortTurn: turnStatus = TurnEndStatusError return turnResult{}, al.hookAbortError(ts, "after_tool", decision) case HookActionHardAbort: _ = ts.requestHardAbort() turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } } if toolResult == nil { toolResult = tools.ErrorResult("hook returned nil tool result") } if len(toolResult.Media) > 0 && toolResult.ResponseHandled { parts := make([]bus.MediaPart, 0, len(toolResult.Media)) for _, ref := range toolResult.Media { part := bus.MediaPart{Ref: ref} if al.mediaStore != nil { if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { part.Filename = meta.Filename part.ContentType = meta.ContentType part.Type = inferMediaType(meta.Filename, meta.ContentType) } } parts = append(parts, part) } outboundMedia := bus.OutboundMediaMessage{ Channel: ts.channel, ChatID: ts.chatID, Context: outboundContextFromInbound( ts.opts.Dispatch.InboundContext, ts.channel, ts.chatID, ts.opts.Dispatch.ReplyToMessageID(), ), AgentID: ts.agent.ID, SessionKey: ts.sessionKey, Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), Parts: parts, } if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { logger.WarnCF("agent", "Failed to deliver handled tool media", map[string]any{ "agent_id": ts.agent.ID, "tool": toolName, "channel": ts.channel, "chat_id": ts.chatID, "error": err.Error(), }) toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err) } } else if al.bus != nil { al.bus.PublishOutboundMedia(ctx, outboundMedia) // Queuing media is only best-effort; it has not been delivered yet. toolResult.ResponseHandled = false } } if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { // For tools like load_image that produce media refs without sending them // to the user channel (ResponseHandled == false), both Media and ArtifactTags // coexist on the result: // - Media: carries media:// refs that resolveMediaRefs will base64-encode // into image_url parts in the next LLM iteration (enabling vision). // - ArtifactTags: exposes the local file path as a structured [file:…] tag // in the tool result text, so the LLM knows an artifact was produced. toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) } if !toolResult.ResponseHandled { allResponsesHandled = false } shouldSendForUser := !toolResult.Silent && toolResult.ForUser != "" && (ts.opts.SendResponse || toolResult.ResponseHandled) if shouldSendForUser { al.bus.PublishOutbound(ctx, outboundMessageForTurn(ts, toolResult.ForUser)) logger.DebugCF("agent", "Sent tool result to user", map[string]any{ "tool": toolName, "content_len": len(toolResult.ForUser), }) } contentForLLM := toolResult.ContentForLLM() // Filter sensitive data (API keys, tokens, secrets) before sending to LLM if al.cfg.Tools.IsFilterSensitiveDataEnabled() { contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) } toolResultMsg := providers.Message{ Role: "tool", Content: contentForLLM, ToolCallID: toolCallID, } if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) } al.emitEvent( EventKindToolExecEnd, ts.eventMeta("runTurn", "turn.tool.end"), ToolExecEndPayload{ Tool: toolName, Duration: toolDuration, ForLLMLen: len(contentForLLM), ForUserLen: len(toolResult.ForUser), IsError: toolResult.IsError, Async: toolResult.Async, }, ) messages = append(messages, toolResultMsg) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) ts.recordPersistedMessage(toolResultMsg) ts.ingestMessage(turnCtx, al, toolResultMsg) } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { pendingMessages = append(pendingMessages, steerMsgs...) } skipReason := "" skipMessage := "" if len(pendingMessages) > 0 { skipReason = "queued user steering message" skipMessage = "Skipped due to queued user message." } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { skipReason = "graceful interrupt requested" skipMessage = "Skipped due to graceful interrupt." } if skipReason != "" { remaining := len(normalizedToolCalls) - i - 1 if remaining > 0 { logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools", map[string]any{ "agent_id": ts.agent.ID, "completed": i + 1, "skipped": remaining, "reason": skipReason, }) for j := i + 1; j < len(normalizedToolCalls); j++ { skippedTC := normalizedToolCalls[j] al.emitEvent( EventKindToolExecSkipped, ts.eventMeta("runTurn", "turn.tool.skipped"), ToolExecSkippedPayload{ Tool: skippedTC.Name, Reason: skipReason, }, ) skippedMsg := providers.Message{ Role: "tool", Content: skipMessage, ToolCallID: skippedTC.ID, } messages = append(messages, skippedMsg) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) ts.recordPersistedMessage(skippedMsg) } } } break } // Also poll for any SubTurn results that arrived during tool execution. if ts.pendingResults != nil { select { case result, ok := <-ts.pendingResults: if ok && result != nil && result.ForLLM != "" { content := al.cfg.FilterSensitiveData(result.ForLLM) msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} messages = append(messages, msg) ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) } default: // No results available } } } if allResponsesHandled { if len(pendingMessages) > 0 { logger.InfoCF("agent", "Pending steering exists after handled tool delivery; continuing turn before finalizing", map[string]any{ "agent_id": ts.agent.ID, "steering_count": len(pendingMessages), "session_key": ts.sessionKey, }) finalContent = "" goto turnLoop } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { logger.InfoCF("agent", "Steering arrived after handled tool delivery; continuing turn before finalizing", map[string]any{ "agent_id": ts.agent.ID, "steering_count": len(steerMsgs), "session_key": ts.sessionKey, }) pendingMessages = append(pendingMessages, steerMsgs...) finalContent = "" goto turnLoop } summaryMsg := providers.Message{ Role: "assistant", Content: handledToolResponseSummary, } if !ts.opts.NoHistory { ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content) ts.recordPersistedMessage(summaryMsg) ts.ingestMessage(turnCtx, al, summaryMsg) if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { turnStatus = TurnEndStatusError al.emitEvent( EventKindError, ts.eventMeta("runTurn", "turn.error"), ErrorPayload{ Stage: "session_save", Message: err.Error(), }, ) return turnResult{}, err } } if ts.opts.EnableSummary { al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize, Budget: ts.agent.ContextWindow}) } ts.setPhase(TurnPhaseCompleted) ts.setFinalContent("") logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, "tool_count": len(normalizedToolCalls), }) return turnResult{ finalContent: "", status: turnStatus, followUps: append([]bus.InboundMessage(nil), ts.followUps...), }, nil } ts.agent.Tools.TickTTL() logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ "agent_id": ts.agent.ID, "iteration": iteration, }) } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { logger.InfoCF("agent", "Steering arrived after turn completion; continuing turn before finalizing", map[string]any{ "agent_id": ts.agent.ID, "steering_count": len(steerMsgs), "session_key": ts.sessionKey, }) pendingMessages = append(pendingMessages, steerMsgs...) finalContent = "" goto turnLoop } if ts.hardAbortRequested() { turnStatus = TurnEndStatusAborted return al.abortTurn(ts) } if finalContent == "" { if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 { finalContent = toolLimitResponse } else { finalContent = ts.opts.DefaultResponse } } ts.setPhase(TurnPhaseFinalizing) ts.setFinalContent(finalContent) if !ts.opts.NoHistory { finalMsg := providers.Message{Role: "assistant", Content: finalContent} ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) ts.recordPersistedMessage(finalMsg) ts.ingestMessage(turnCtx, al, finalMsg) if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { turnStatus = TurnEndStatusError al.emitEvent( EventKindError, ts.eventMeta("runTurn", "turn.error"), ErrorPayload{ Stage: "session_save", Message: err.Error(), }, ) return turnResult{}, err } } if ts.opts.EnableSummary { al.contextManager.Compact( turnCtx, &CompactRequest{ SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize, Budget: ts.agent.ContextWindow, }, ) } ts.setPhase(TurnPhaseCompleted) return turnResult{ finalContent: finalContent, status: turnStatus, followUps: append([]bus.InboundMessage(nil), ts.followUps...), }, nil } func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) { ts.setPhase(TurnPhaseAborted) if !ts.opts.NoHistory { if err := ts.restoreSession(ts.agent); err != nil { al.emitEvent( EventKindError, ts.eventMeta("abortTurn", "turn.error"), ErrorPayload{ Stage: "session_restore", Message: err.Error(), }, ) return turnResult{}, err } } return turnResult{status: TurnEndStatusAborted}, nil } func (al *AgentLoop) selectCandidates( agent *AgentInstance, userMsg string, history []providers.Message, ) (candidates []providers.FallbackCandidate, model string, usedLight bool) { if agent.Router == nil || len(agent.LightCandidates) == 0 { return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false } _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) if !usedLight { logger.DebugCF("agent", "Model routing: primary model selected", map[string]any{ "agent_id": agent.ID, "score": score, "threshold": agent.Router.Threshold(), }) return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false } logger.InfoCF("agent", "Model routing: light model selected", map[string]any{ "agent_id": agent.ID, "light_model": agent.Router.LightModel(), "score": score, "threshold": agent.Router.Threshold(), }) return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true } func (al *AgentLoop) resolveContextManager() ContextManager { name := al.cfg.Agents.Defaults.ContextManager if name == "" || name == "legacy" { return &legacyContextManager{al: al} } factory, ok := lookupContextManager(name) if !ok { logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ "name": name, }) return &legacyContextManager{al: al} } cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) if err != nil { logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ "name": name, "error": err.Error(), }) return &legacyContextManager{al: al} } return cm } func (al *AgentLoop) askSideQuestion( ctx context.Context, agent *AgentInstance, opts *processOptions, question string, ) (string, error) { if agent == nil { return "", fmt.Errorf("askSideQuestion: no agent available for /btw") } question = strings.TrimSpace(question) if question == "" { return "", fmt.Errorf("askSideQuestion: %w", fmt.Errorf("Usage: /btw ")) } if opts != nil { normalizeProcessOptionsInPlace(opts) } var media []string var channel, chatID, senderID, senderDisplayName string if opts != nil { media = opts.Media channel = opts.Channel chatID = opts.ChatID senderID = opts.SenderID senderDisplayName = opts.SenderDisplayName } // Build messages with context but WITHOUT adding to session history var history []providers.Message var summary string if opts != nil && !opts.NoHistory { if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{ SessionKey: opts.SessionKey, Budget: agent.ContextWindow, MaxTokens: agent.MaxTokens, }); err == nil && resp != nil { history = resp.History summary = resp.Summary } } messages := agent.ContextBuilder.BuildMessages( history, summary, question, media, channel, chatID, senderID, senderDisplayName, ) maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize() messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages) selectedModelName := sideQuestionModelName(agent, usedLight) llmOpts := map[string]any{ "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, "prompt_cache_key": agent.ID + ":btw", } hookModelChanged := false callProvider := func( ctx context.Context, candidate providers.FallbackCandidate, model string, forceModel bool, callMessages []providers.Message, ) (*providers.LLMResponse, error) { provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate) if err != nil { return nil, err } defer cleanup() if !forceModel || strings.TrimSpace(model) == "" { model = providerModel } callOpts := llmOpts if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff { if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { callOpts = shallowCloneLLMOptions(llmOpts) callOpts["thinking_level"] = string(agent.ThinkingLevel) } } return provider.Chat(ctx, callMessages, nil, model, callOpts) } turnCtx := newTurnContext(nil, nil, nil) if opts != nil { turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope) } llmModel := activeModel if al.hooks != nil { llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{ Meta: EventMeta{ Source: "askSideQuestion", TracePath: "turn.llm.request", turnContext: cloneTurnContext(turnCtx), }, Context: cloneTurnContext(turnCtx), Model: llmModel, Messages: messages, Tools: nil, Options: llmOpts, GracefulTerminal: false, }) switch decision.normalizedAction() { case HookActionContinue, HookActionModify: if llmReq != nil { if strings.TrimSpace(llmReq.Model) != "" && llmReq.Model != llmModel { hookModelChanged = true } llmModel = llmReq.Model messages = llmReq.Messages llmOpts = llmReq.Options } case HookActionAbortTurn: reason := decision.Reason if reason == "" { reason = "hook requested turn abort" } return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) case HookActionHardAbort: reason := decision.Reason if reason == "" { reason = "hook requested turn abort" } return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) } } if hookModelChanged { // Hook-selected models must not continue through the pre-hook fallback // candidate list, otherwise fallback execution would call the original // candidate model and silently ignore the hook decision. activeCandidates = nil } callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) { if len(activeCandidates) > 1 && al.fallback != nil { fbResult, err := al.fallback.Execute( ctx, activeCandidates, func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) { candidate := providers.FallbackCandidate{Provider: providerName, Model: model} for _, activeCandidate := range activeCandidates { if activeCandidate.Provider == providerName && activeCandidate.Model == model { candidate = activeCandidate break } } return callProvider(ctx, candidate, model, false, callMessages) }, ) if err != nil { return nil, err } return fbResult.Response, nil } var candidate providers.FallbackCandidate if len(activeCandidates) > 0 { candidate = activeCandidates[0] } return callProvider(ctx, candidate, llmModel, hookModelChanged, callMessages) } // Retry without media if vision is unsupported // Note: Vision retry is only applied to the initial call. If fallback chain // is used, vision errors from fallback providers will not trigger retry. var resp *providers.LLMResponse var err error resp, err = callSideLLM(messages) if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) { al.emitEvent( EventKindLLMRetry, EventMeta{ Source: "askSideQuestion", TracePath: "turn.llm.retry", turnContext: cloneTurnContext(turnCtx), }, LLMRetryPayload{ Attempt: 1, MaxRetries: 1, Reason: "vision_unsupported", Error: err.Error(), Backoff: 0, }, ) messagesWithoutMedia := stripMessageMedia(messages) resp, err = callSideLLM(messagesWithoutMedia) } if err != nil { return "", err } if resp == nil { return "", nil } // Apply after_llm hooks if al.hooks != nil { llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ Meta: EventMeta{ Source: "askSideQuestion", TracePath: "turn.llm.response", turnContext: cloneTurnContext(turnCtx), }, Context: cloneTurnContext(turnCtx), Model: llmModel, Response: resp, }) switch decision.normalizedAction() { case HookActionContinue, HookActionModify: if llmResp != nil && llmResp.Response != nil { resp = llmResp.Response } case HookActionAbortTurn, HookActionHardAbort: reason := decision.Reason if reason == "" { reason = "hook requested turn abort" } return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) } } return sideQuestionResponseContent(resp), nil } func (al *AgentLoop) isolatedSideQuestionProvider( agent *AgentInstance, baseModelName string, candidate providers.FallbackCandidate, ) (providers.LLMProvider, string, func(), error) { if agent == nil { return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw") } modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate) if err != nil { return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) } factory := al.providerFactory if factory == nil { factory = providers.CreateProviderFromConfig } provider, modelID, err := factory(modelCfg) if err != nil { return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) } cleanup := func() { closeProviderIfStateful(provider) } return provider, modelID, cleanup, nil } func (al *AgentLoop) sideQuestionModelConfig( agent *AgentInstance, baseModelName string, candidate providers.FallbackCandidate, ) (*config.ModelConfig, error) { if agent == nil { return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw") } // If candidate has an identity key, use that if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" { modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace) if err == nil { return modelCfg, nil } // Fallback: create a minimal config if lookup fails } // Otherwise, clean up the base model name and use it baseModelName = strings.TrimSpace(baseModelName) modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace) if err != nil { // Fallback: create a minimal config for test scenarios model := strings.TrimSpace(baseModelName) if candidate.Model != "" { model = candidate.Model } if candidate.Provider != "" && candidate.Model != "" { model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model } else { model = ensureProtocolModel(model) } return &config.ModelConfig{ ModelName: baseModelName, Model: model, Workspace: agent.Workspace, }, nil } // If candidate specifies a different provider/model, override clone := *modelCfg if candidate.Provider != "" && candidate.Model != "" { clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model } return &clone, nil }