refactor: reduce runLLMIteration complexity from 94 to 19

Extract callLLMWithRetry, cleanLLMResponse, executeToolCalls,
buildAssistantMessage, publishToolMedia, forceTextResponse as
separate methods. Add no-op defaults to iterationHooks eliminating
all nil checks in the main loop.

Cyclomatic complexity: 145 → 94 → 19.
Branching statements: 109 → 59 → 14.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-13 11:49:41 +09:00
parent eeb9392b89
commit 0fd3cb3156
2 changed files with 408 additions and 384 deletions

View file

@ -1841,12 +1841,9 @@ func (al *AgentLoop) runLLMIteration(
for iteration < agent.MaxIterations { for iteration < agent.MaxIterations {
iteration++ iteration++
// Hook: iteration start (task tracking, user intervention)
if hooks.OnIterationStart != nil {
if msg := hooks.OnIterationStart(iteration); msg != "" { if msg := hooks.OnIterationStart(iteration); msg != "" {
messages = append(messages, providers.Message{Role: "user", Content: msg}) messages = append(messages, providers.Message{Role: "user", Content: msg})
} }
}
logger.DebugCF("agent", "LLM iteration", logger.DebugCF("agent", "LLM iteration",
map[string]any{ map[string]any{
@ -1856,22 +1853,15 @@ func (al *AgentLoop) runLLMIteration(
}) })
// Build tool definitions // Build tool definitions
providerToolDefs := agent.Tools.ToProviderDefs() providerToolDefs := hooks.FilterTools(agent.Tools.ToProviderDefs())
// Hook: tool filtering (interview mode)
if hooks.FilterTools != nil {
providerToolDefs = hooks.FilterTools(providerToolDefs)
}
// Resolve model and candidates for this call // Resolve model and candidates for this call
candidates := agent.Candidates candidates := agent.Candidates
activeModel := agent.Model activeModel := agent.Model
if hooks.SelectModel != nil {
if m, c := hooks.SelectModel(); m != "" { if m, c := hooks.SelectModel(); m != "" {
activeModel = m activeModel = m
candidates = c candidates = c
} }
}
// Log LLM request details // Log LLM request details
logger.DebugCF("agent", "LLM request", logger.DebugCF("agent", "LLM request",
@ -1885,8 +1875,6 @@ func (al *AgentLoop) runLLMIteration(
"temperature": agent.Temperature, "temperature": agent.Temperature,
"system_prompt_len": len(messages[0].Content), "system_prompt_len": len(messages[0].Content),
}) })
// Log full messages (detailed)
logger.DebugCF("agent", "Full LLM request", logger.DebugCF("agent", "Full LLM request",
map[string]any{ map[string]any{
"iteration": iteration, "iteration": iteration,
@ -1894,17 +1882,143 @@ func (al *AgentLoop) runLLMIteration(
"tools_json": formatToolsForLog(providerToolDefs), "tools_json": formatToolsForLog(providerToolDefs),
}) })
// Hook: streaming setup // Streaming setup
var onChunk func(string, string) onChunk, streamCleanup := hooks.SetupStreaming()
var streamCleanup func()
if hooks.SetupStreaming != nil { hooks.OnPreLLMCall()
onChunk, streamCleanup = hooks.SetupStreaming()
// Call LLM with retry
response, err := al.callLLMWithRetry(ctx, agent, &messages, opts,
providerToolDefs, candidates, activeModel, onChunk, iteration)
// Streaming cleanup
if streamCleanup != nil {
onChunk = nil
streamCleanup()
} }
// Build LLM call functions if err != nil {
var response *providers.LLMResponse logger.ErrorCF("agent", "LLM call failed",
var err error map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"error": err.Error(),
})
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
}
// Record token usage
if response.Usage != nil && al.stats != nil {
al.stats.RecordUsage(
response.Usage.PromptTokens,
response.Usage.CompletionTokens,
response.Usage.TotalTokens,
)
}
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
logger.DebugCF("agent", "LLM response",
map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(response.Content),
"tool_calls": len(response.ToolCalls),
"reasoning": response.Reasoning,
"target_channel": al.targetReasoningChannelID(opts.Channel),
"channel": opts.Channel,
})
// Clean up response content
response = al.cleanLLMResponse(ctx, response, &messages, agent, iteration,
providerToolDefs, candidates, activeModel, onChunk)
// No tool calls — check for plan nudge or return
if len(response.ToolCalls) == 0 {
if nudge, cont := hooks.OnNoToolCalls(response.Content, iteration); cont {
messages = append(messages,
providers.Message{Role: "assistant", Content: response.Content},
providers.Message{Role: "user", Content: nudge},
)
continue
}
finalContent = response.Content
if finalContent == "" && response.ReasoningContent != "" {
finalContent = response.ReasoningContent
}
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(finalContent),
})
break
}
// Normalize and filter tool calls
normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls {
normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
}
filtered, rejMsg := hooks.FilterToolCalls(normalizedToolCalls)
if len(filtered) < len(normalizedToolCalls) && rejMsg != "" {
messages = append(messages, providers.Message{Role: "user", Content: rejMsg})
}
normalizedToolCalls = filtered
if len(normalizedToolCalls) == 0 {
continue
}
// Log tool calls
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": agent.ID,
"tools": toolNames,
"count": len(normalizedToolCalls),
"iteration": iteration,
})
hooks.OnToolsProcessed(ctx, iteration, normalizedToolCalls)
// Build and save assistant message
assistantMsg := buildAssistantMessage(response, normalizedToolCalls)
messages = append(messages, assistantMsg)
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Execute tool calls and collect results
lastBlocker := al.executeToolCalls(ctx, agent, normalizedToolCalls, &messages, opts, hooks, iteration)
hooks.InjectReminders(iteration, &messages, lastBlocker)
hooks.RefreshSystemPrompt(messages)
}
// Force a final text response if max iterations exhausted
if finalContent == "" && iteration >= agent.MaxIterations {
finalContent = al.forceTextResponse(ctx, agent, messages)
}
return finalContent, iteration, nil
}
// callLLMWithRetry calls the LLM with streaming support, fallback chain,
// and retry logic for timeout and context window errors.
func (al *AgentLoop) callLLMWithRetry(
ctx context.Context,
agent *AgentInstance,
messages *[]providers.Message,
opts processOptions,
toolDefs []providers.ToolDefinition,
candidates []providers.FallbackCandidate,
activeModel string,
onChunk func(string, string),
iteration int,
) (*providers.LLMResponse, error) {
llmOpts := map[string]any{ llmOpts := map[string]any{
"max_tokens": agent.MaxTokens, "max_tokens": agent.MaxTokens,
"temperature": agent.Temperature, "temperature": agent.Temperature,
@ -1915,7 +2029,7 @@ func (al *AgentLoop) runLLMIteration(
if sp, ok := p.(providers.StreamingProvider); ok && sp.CanStream() { if sp, ok := p.(providers.StreamingProvider); ok && sp.CanStream() {
streamCtx, streamCancel := context.WithCancel(ctx) streamCtx, streamCancel := context.WithCancel(ctx)
defer streamCancel() defer streamCancel()
ch, sErr := sp.ChatStream(streamCtx, messages, providerToolDefs, model, llmOpts) ch, sErr := sp.ChatStream(streamCtx, *messages, toolDefs, model, llmOpts)
if sErr != nil { if sErr != nil {
return nil, sErr return nil, sErr
} }
@ -1928,7 +2042,7 @@ func (al *AgentLoop) runLLMIteration(
} }
return resp, nil return resp, nil
} }
return p.Chat(ctx, messages, providerToolDefs, model, llmOpts) return p.Chat(ctx, *messages, toolDefs, model, llmOpts)
} }
callLLM := func() (*providers.LLMResponse, error) { callLLM := func() (*providers.LLMResponse, error) {
@ -1949,39 +2063,34 @@ func (al *AgentLoop) runLLMIteration(
} }
return fbResult.Response, nil return fbResult.Response, nil
} }
if len(candidates) > 0 { if len(candidates) > 0 {
c := candidates[0] c := candidates[0]
p := al.resolveProvider(c.Provider, c.Model, agent.Provider) p := al.resolveProvider(c.Provider, c.Model, agent.Provider)
return doCall(ctx, p, c.Model) return doCall(ctx, p, c.Model)
} }
return doCall(ctx, agent.Provider, activeModel) return doCall(ctx, agent.Provider, activeModel)
} }
// Hook: pre-LLM state reporting // Hook: pre-LLM state reporting (called via hooks in the caller)
if hooks.OnPreLLMCall != nil {
hooks.OnPreLLMCall()
}
// Retry loop for context/token errors
maxRetries := 2 maxRetries := 2
var response *providers.LLMResponse
var err error
for retry := 0; retry <= maxRetries; retry++ { for retry := 0; retry <= maxRetries; retry++ {
response, err = callLLM() response, err = callLLM()
if err == nil { if err == nil {
break return response, nil
} }
errMsg := strings.ToLower(err.Error()) errMsg := strings.ToLower(err.Error())
// Check if this is a network/HTTP timeout — not a context window error.
isTimeoutError := errors.Is(err, context.DeadlineExceeded) || isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(errMsg, "deadline exceeded") || strings.Contains(errMsg, "deadline exceeded") ||
strings.Contains(errMsg, "client.timeout") || strings.Contains(errMsg, "client.timeout") ||
strings.Contains(errMsg, "timed out") || strings.Contains(errMsg, "timed out") ||
strings.Contains(errMsg, "timeout exceeded") strings.Contains(errMsg, "timeout exceeded")
// Detect real context window / token limit errors, excluding network timeouts.
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_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, "maximum context length") ||
@ -2008,7 +2117,6 @@ func (al *AgentLoop) runLLMIteration(
"error": err.Error(), "error": err.Error(),
"retry": retry, "retry": retry,
}) })
if retry == 0 && !constants.IsInternalChannel(opts.Channel) { if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel, Channel: opts.Channel,
@ -2016,11 +2124,10 @@ func (al *AgentLoop) runLLMIteration(
Content: "Context window exceeded. Compressing history and retrying...", Content: "Context window exceeded. Compressing history and retrying...",
}) })
} }
al.forceCompression(agent, opts.SessionKey) al.forceCompression(agent, opts.SessionKey)
newHistory := agent.Sessions.GetHistory(opts.SessionKey) newHistory := agent.Sessions.GetHistory(opts.SessionKey)
newSummary := agent.Sessions.GetSummary(opts.SessionKey) newSummary := agent.Sessions.GetSummary(opts.SessionKey)
messages = agent.ContextBuilder.BuildMessages( *messages = agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "", newHistory, newSummary, "",
nil, opts.Channel, opts.ChatID, nil, opts.Channel, opts.ChatID,
) )
@ -2028,48 +2135,22 @@ func (al *AgentLoop) runLLMIteration(
} }
break break
} }
return nil, err
// Streaming cleanup
if streamCleanup != nil {
onChunk = nil // prevent writes after close
streamCleanup()
streamCleanup = nil
} }
if err != nil { // cleanLLMResponse handles repetition detection, think block stripping,
logger.ErrorCF("agent", "LLM call failed", // and XML tool call extraction on the raw LLM response.
map[string]any{ func (al *AgentLoop) cleanLLMResponse(
"agent_id": agent.ID, ctx context.Context,
"iteration": iteration, response *providers.LLMResponse,
"error": err.Error(), messages *[]providers.Message,
}) agent *AgentInstance,
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) iteration int,
} toolDefs []providers.ToolDefinition,
candidates []providers.FallbackCandidate,
// Record token usage activeModel string,
if response.Usage != nil && al.stats != nil { onChunk func(string, string),
al.stats.RecordUsage( ) *providers.LLMResponse {
response.Usage.PromptTokens,
response.Usage.CompletionTokens,
response.Usage.TotalTokens,
)
}
// Handle reasoning output (best-effort, non-blocking)
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
logger.DebugCF("agent", "LLM response",
map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(response.Content),
"tool_calls": len(response.ToolCalls),
"reasoning": response.Reasoning,
"target_channel": al.targetReasoningChannelID(opts.Channel),
"channel": opts.Channel,
})
// Detect repetition loop
if response.FinishReason == "repetition_detected" || if response.FinishReason == "repetition_detected" ||
(len(response.ToolCalls) == 0 && utils.DetectRepetitionLoop(response.Content)) { (len(response.ToolCalls) == 0 && utils.DetectRepetitionLoop(response.Content)) {
logger.WarnCF("agent", "Repetition loop detected in LLM response, retrying", logger.WarnCF("agent", "Repetition loop detected in LLM response, retrying",
@ -2080,20 +2161,20 @@ func (al *AgentLoop) runLLMIteration(
"content_length": len(response.Content), "content_length": len(response.Content),
}) })
savedMsgs := messages savedMsgs := *messages
messages = append(append([]providers.Message(nil), messages...), *messages = append(append([]providers.Message(nil), *messages...),
providers.Message{ providers.Message{
Role: "user", Role: "user",
Content: "[System] Your previous response contained degenerate repetition and was discarded. Please respond normally without repeating yourself.", Content: "[System] Your previous response contained degenerate repetition and was discarded. Please respond normally without repeating yourself.",
}) })
response, err = callLLM() retryResp, retryErr := al.callLLMWithRetry(ctx, agent, messages, processOptions{},
messages = savedMsgs toolDefs, candidates, activeModel, onChunk, iteration)
*messages = savedMsgs
if err != nil { if retryErr == nil {
return "", iteration, fmt.Errorf("LLM retry after repetition failed: %w", err) response = retryResp
} }
if utils.DetectRepetitionLoop(response.Content) { if utils.DetectRepetitionLoop(response.Content) {
logger.ErrorCF("agent", "Repetition persists after retry, returning empty", logger.ErrorCF("agent", "Repetition persists after retry, returning empty",
map[string]any{"agent_id": agent.ID}) map[string]any{"agent_id": agent.ID})
@ -2101,7 +2182,6 @@ func (al *AgentLoop) runLLMIteration(
} }
} }
// Strip think blocks and extract XML tool calls
response.Content = utils.StripThinkBlocks(response.Content) response.Content = utils.StripThinkBlocks(response.Content)
if len(response.ToolCalls) == 0 { if len(response.ToolCalls) == 0 {
if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 { if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 {
@ -2109,81 +2189,23 @@ func (al *AgentLoop) runLLMIteration(
} }
} }
response.Content = providers.StripXMLToolCalls(response.Content) response.Content = providers.StripXMLToolCalls(response.Content)
return response
// Check if no tool calls
if len(response.ToolCalls) == 0 {
// Hook: plan continuation nudge
if hooks.OnNoToolCalls != nil {
if nudge, cont := hooks.OnNoToolCalls(response.Content, iteration); cont {
messages = append(messages,
providers.Message{Role: "assistant", Content: response.Content},
providers.Message{Role: "user", Content: nudge},
)
continue
}
} }
finalContent = response.Content // buildAssistantMessage constructs the assistant message with tool calls.
if finalContent == "" && response.ReasoningContent != "" { func buildAssistantMessage(response *providers.LLMResponse, toolCalls []providers.ToolCall) providers.Message {
finalContent = response.ReasoningContent msg := providers.Message{
}
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
map[string]any{
"agent_id": 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))
}
// Hook: interview rejection
if hooks.FilterToolCalls != nil {
filtered, rejMsg := hooks.FilterToolCalls(normalizedToolCalls)
if len(filtered) < len(normalizedToolCalls) && rejMsg != "" {
messages = append(messages, providers.Message{Role: "user", Content: rejMsg})
}
normalizedToolCalls = filtered
if len(normalizedToolCalls) == 0 {
continue
}
}
// Log tool calls
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": agent.ID,
"tools": toolNames,
"count": len(normalizedToolCalls),
"iteration": iteration,
})
// Hook: publish tool status and record session touches
if hooks.OnToolsProcessed != nil {
hooks.OnToolsProcessed(ctx, iteration, normalizedToolCalls)
}
// Build assistant message with tool calls
assistantMsg := providers.Message{
Role: "assistant", Role: "assistant",
Content: response.Content, Content: response.Content,
ReasoningContent: response.ReasoningContent, ReasoningContent: response.ReasoningContent,
} }
for _, tc := range normalizedToolCalls { for _, tc := range toolCalls {
extraContent := tc.ExtraContent extraContent := tc.ExtraContent
thoughtSignature := "" thoughtSignature := ""
if tc.Function != nil { if tc.Function != nil {
thoughtSignature = tc.Function.ThoughtSignature thoughtSignature = tc.Function.ThoughtSignature
} }
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ msg.ToolCalls = append(msg.ToolCalls, providers.ToolCall{
ID: tc.ID, ID: tc.ID,
Type: "function", Type: "function",
Name: tc.Name, Name: tc.Name,
@ -2197,14 +2219,22 @@ func (al *AgentLoop) runLLMIteration(
ThoughtSignature: thoughtSignature, ThoughtSignature: thoughtSignature,
}) })
} }
messages = append(messages, assistantMsg) return msg
}
// Save assistant message with tool calls to session // executeToolCalls runs each tool call sequentially, publishes results,
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) // and returns the last blocker (error content) for reminder injection.
func (al *AgentLoop) executeToolCalls(
// Execute tool calls ctx context.Context,
agent *AgentInstance,
toolCalls []providers.ToolCall,
messages *[]providers.Message,
opts processOptions,
hooks iterationHooks,
iteration int,
) string {
var lastBlocker string var lastBlocker string
for _, tc := range normalizedToolCalls { for _, tc := range toolCalls {
argsJSON, _ := json.Marshal(tc.Arguments) argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200) argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
@ -2223,11 +2253,7 @@ func (al *AgentLoop) runLLMIteration(
} }
} }
// Hook: pre-tool execution (async callback, orch state) asyncCallback := hooks.OnPreToolExec(ctx, tc)
var asyncCallback tools.AsyncCallback
if hooks.OnPreToolExec != nil {
asyncCallback = hooks.OnPreToolExec(ctx, tc)
}
toolStart := time.Now() toolStart := time.Now()
toolCtx := ctx toolCtx := ctx
@ -2242,12 +2268,9 @@ func (al *AgentLoop) runLLMIteration(
) )
toolDuration := time.Since(toolStart) toolDuration := time.Since(toolStart)
// Hook: post-tool execution (task log update)
if hooks.OnToolExecDone != nil {
hooks.OnToolExecDone(tc, toolResult, toolDuration) hooks.OnToolExecDone(tc, toolResult, toolDuration)
}
// Send ForUser content to user immediately if not Silent // Publish results to user
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel, Channel: opts.Channel,
@ -2258,10 +2281,34 @@ func (al *AgentLoop) runLLMIteration(
map[string]any{"tool": tc.Name, "content_len": len(toolResult.ForUser)}) map[string]any{"tool": tc.Name, "content_len": len(toolResult.ForUser)})
} }
// If tool returned media refs, publish them as outbound media
if len(toolResult.Media) > 0 && opts.SendResponse { if len(toolResult.Media) > 0 && opts.SendResponse {
parts := make([]bus.MediaPart, 0, len(toolResult.Media)) al.publishToolMedia(ctx, toolResult, opts)
for _, ref := range toolResult.Media { }
// Build tool result message
contentForLLM := toolResult.ForLLM
if contentForLLM == "" && toolResult.Err != nil {
contentForLLM = toolResult.Err.Error()
}
if toolResult.IsError || toolResult.Err != nil {
lastBlocker = contentForLLM
}
toolResultMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: tc.ID,
}
*messages = append(*messages, toolResultMsg)
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
}
return lastBlocker
}
// publishToolMedia publishes media refs from a tool result as outbound media.
func (al *AgentLoop) publishToolMedia(ctx context.Context, result *tools.ToolResult, opts processOptions) {
parts := make([]bus.MediaPart, 0, len(result.Media))
for _, ref := range result.Media {
part := bus.MediaPart{Ref: ref} part := bus.MediaPart{Ref: ref}
if al.mediaStore != nil { if al.mediaStore != nil {
if _, meta, mErr := al.mediaStore.ResolveWithMeta(ref); mErr == nil { if _, meta, mErr := al.mediaStore.ResolveWithMeta(ref); mErr == nil {
@ -2279,56 +2326,21 @@ func (al *AgentLoop) runLLMIteration(
}) })
} }
// Determine content for LLM based on tool result // forceTextResponse makes a final LLM call without tools when max iterations
contentForLLM := toolResult.ForLLM // are exhausted, forcing a text response.
if contentForLLM == "" && toolResult.Err != nil { func (al *AgentLoop) forceTextResponse(ctx context.Context, agent *AgentInstance, messages []providers.Message) string {
contentForLLM = toolResult.Err.Error()
}
// Track blockers for task reminder
if toolResult.IsError || toolResult.Err != nil {
lastBlocker = contentForLLM
}
toolResultMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
// Save tool result message to session
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
}
// Hook: inject reminders and trim tool log
if hooks.InjectReminders != nil {
hooks.InjectReminders(iteration, &messages, lastBlocker)
}
// Hook: refresh system prompt
if hooks.RefreshSystemPrompt != nil {
hooks.RefreshSystemPrompt(messages)
}
}
// If max iterations exhausted with tool calls still pending,
// make one final LLM call without tools to force a text response.
if finalContent == "" && iteration >= agent.MaxIterations {
logger.WarnCF("agent", "Max iterations reached, forcing final response without tools", logger.WarnCF("agent", "Max iterations reached, forcing final response without tools",
map[string]any{ map[string]any{"agent_id": agent.ID})
"agent_id": agent.ID,
"iteration": iteration,
})
forceResp, forceErr := agent.Provider.Chat(ctx, messages, nil, agent.Model, map[string]any{ forceResp, forceErr := agent.Provider.Chat(ctx, messages, nil, agent.Model, map[string]any{
"max_tokens": agent.MaxTokens, "max_tokens": agent.MaxTokens,
"temperature": agent.Temperature, "temperature": agent.Temperature,
"prompt_cache_key": agent.ID, "prompt_cache_key": agent.ID,
}) })
if forceErr != nil || forceResp.Content == "" {
if forceErr == nil && forceResp.Content != "" { return ""
finalContent = utils.StripThinkBlocks(forceResp.Content) }
content := utils.StripThinkBlocks(forceResp.Content)
if forceResp.Usage != nil && al.stats != nil { if forceResp.Usage != nil && al.stats != nil {
al.stats.RecordUsage( al.stats.RecordUsage(
forceResp.Usage.PromptTokens, forceResp.Usage.PromptTokens,
@ -2336,10 +2348,7 @@ func (al *AgentLoop) runLLMIteration(
forceResp.Usage.TotalTokens, forceResp.Usage.TotalTokens,
) )
} }
} return content
}
return finalContent, iteration, nil
} }
// updateToolContexts updates the context for tools that need channel/chatID info. // updateToolContexts updates the context for tools that need channel/chatID info.

View file

@ -16,9 +16,9 @@ import (
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
// iterationHooks contains optional callbacks that extend the core LLM // iterationHooks contains callbacks that extend the core LLM iteration loop.
// iteration loop. Each hook is nil when the corresponding fork feature // All fields are initialized to no-op defaults by buildHooks, so callers
// is inactive, keeping the core loop close to upstream's structure. // never need nil checks.
type iterationHooks struct { type iterationHooks struct {
// OnIterationStart is called at the top of each iteration. // OnIterationStart is called at the top of each iteration.
// Returns an optional user-role message to inject (e.g. user intervention). // Returns an optional user-role message to inject (e.g. user intervention).
@ -30,7 +30,6 @@ type iterationHooks struct {
// SetupStreaming is called before each LLM call to set up streaming // SetupStreaming is called before each LLM call to set up streaming
// preview. Returns an onChunk callback and a cleanup function. // preview. Returns an onChunk callback and a cleanup function.
// Both may be nil if streaming is not applicable.
SetupStreaming func() (onChunk func(accumulated, reasoning string), cleanup func()) SetupStreaming func() (onChunk func(accumulated, reasoning string), cleanup func())
// SelectModel overrides the model and candidates for this call. // SelectModel overrides the model and candidates for this call.
@ -46,7 +45,6 @@ type iterationHooks struct {
// FilterToolCalls is called after normalizing tool calls, before execution. // FilterToolCalls is called after normalizing tool calls, before execution.
// Returns the filtered calls and an optional rejection message. // Returns the filtered calls and an optional rejection message.
// If all calls are filtered out, the loop continues with the rejection message.
FilterToolCalls func(calls []providers.ToolCall) (filtered []providers.ToolCall, rejectionMsg string) FilterToolCalls func(calls []providers.ToolCall) (filtered []providers.ToolCall, rejectionMsg string)
// OnPreToolExec is called before each tool execution. // OnPreToolExec is called before each tool execution.
@ -57,8 +55,7 @@ type iterationHooks struct {
OnToolExecDone func(tc providers.ToolCall, result *tools.ToolResult, duration time.Duration) OnToolExecDone func(tc providers.ToolCall, result *tools.ToolResult, duration time.Duration)
// OnToolsProcessed is called after all tool calls in an iteration // OnToolsProcessed is called after all tool calls in an iteration
// have been logged and their results built. Receives the tool call // have been logged and their results built.
// list for status publishing and session-touch recording.
OnToolsProcessed func(ctx context.Context, iteration int, toolCalls []providers.ToolCall) OnToolsProcessed func(ctx context.Context, iteration int, toolCalls []providers.ToolCall)
// InjectReminders is called at the end of each iteration to append // InjectReminders is called at the end of each iteration to append
@ -70,6 +67,24 @@ type iterationHooks struct {
RefreshSystemPrompt func(messages []providers.Message) RefreshSystemPrompt func(messages []providers.Message)
} }
// defaultHooks returns an iterationHooks with all fields set to no-ops.
func defaultHooks() iterationHooks {
return iterationHooks{
OnIterationStart: func(int) string { return "" },
FilterTools: func(d []providers.ToolDefinition) []providers.ToolDefinition { return d },
SetupStreaming: func() (func(string, string), func()) { return nil, nil },
SelectModel: func() (string, []providers.FallbackCandidate) { return "", nil },
OnPreLLMCall: func() {},
OnNoToolCalls: func(string, int) (string, bool) { return "", false },
FilterToolCalls: func(c []providers.ToolCall) ([]providers.ToolCall, string) { return c, "" },
OnPreToolExec: func(context.Context, providers.ToolCall) tools.AsyncCallback { return nil },
OnToolExecDone: func(providers.ToolCall, *tools.ToolResult, time.Duration) {},
OnToolsProcessed: func(context.Context, int, []providers.ToolCall) {},
InjectReminders: func(int, *[]providers.Message, string) {},
RefreshSystemPrompt: func([]providers.Message) {},
}
}
// buildHooks constructs the hook set based on the current agent state. // buildHooks constructs the hook set based on the current agent state.
// All fork-specific logic is wired here; the core loop only calls hooks. // All fork-specific logic is wired here; the core loop only calls hooks.
func (al *AgentLoop) buildHooks( func (al *AgentLoop) buildHooks(
@ -78,7 +93,7 @@ func (al *AgentLoop) buildHooks(
task *activeTask, task *activeTask,
planSnapshot string, planSnapshot string,
) iterationHooks { ) iterationHooks {
h := iterationHooks{} h := defaultHooks()
isBackground := opts.TaskID != "" isBackground := opts.TaskID != ""
// ── Task tracking ── // ── Task tracking ──