picoclaw/pkg/agent/loop_execute_llm.go
google-labs-jules[bot] 48c6492f41 chore(agent): extract agent loop logic into separate files
Refactored `runLLMIteration` in `pkg/agent/loop.go`.
Extracted LLM calling logic to `executeLLMWithRetry` in `pkg/agent/loop_execute_llm.go`.
Extracted parallel tool execution logic to `executeToolBatch` in `pkg/agent/loop_execute_tools.go`.
Added panic recovery to `executeToolBatch` goroutines.
Updated retry logic in `executeLLMWithRetry` to use exponential backoff instead of linear backoff.
Created `AGENT_LOOP_IMPROVEMENTS.md` to track further planned enhancements.

Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
2026-03-13 09:47:55 +00:00

154 lines
4.5 KiB
Go

package agent
import (
"context"
"errors"
"fmt"
"strings"
"time"
"jane/pkg/bus"
"jane/pkg/constants"
"jane/pkg/logger"
"jane/pkg/providers"
)
// executeLLMWithRetry handles calling the LLM, managing the fallback chain, and retrying
// on timeouts or context window limits.
func (al *AgentLoop) executeLLMWithRetry(
ctx context.Context,
agent *AgentInstance,
opts processOptions,
messages *[]providers.Message, // pointer to allow replacing messages slice on compression
providerToolDefs []providers.ToolDefinition,
activeCandidates []providers.FallbackCandidate,
activeModel string,
iteration int,
) (*providers.LLMResponse, error) {
llmOpts := map[string]any{
"max_tokens": agent.MaxTokens,
"temperature": agent.Temperature,
"prompt_cache_key": agent.ID,
}
if agent.ThinkingLevel != ThinkingOff {
if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
llmOpts["thinking_level"] = string(agent.ThinkingLevel)
} else {
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)})
}
}
callLLM := func() (*providers.LLMResponse, error) {
if len(activeCandidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute(
ctx,
activeCandidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
return agent.Provider.Chat(ctx, *messages, providerToolDefs, 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": agent.ID, "iteration": iteration},
)
}
return fbResult.Response, nil
}
return agent.Provider.Chat(ctx, *messages, providerToolDefs, activeModel, llmOpts)
}
var response *providers.LLMResponse
var err error
// Retry loop for context/token errors
maxRetries := 2
for retry := 0; retry <= maxRetries; retry++ {
response, err = callLLM()
if err == nil {
break
}
errMsg := strings.ToLower(err.Error())
// Check if this is a network/HTTP timeout — not a context window 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")
// Detect real context window / token limit errors, excluding network timeouts.
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
strings.Contains(errMsg, "context window") ||
strings.Contains(errMsg, "maximum context length") ||
strings.Contains(errMsg, "token limit") ||
strings.Contains(errMsg, "too many tokens") ||
strings.Contains(errMsg, "max_tokens") ||
strings.Contains(errMsg, "invalidparameter") ||
strings.Contains(errMsg, "prompt is too long") ||
strings.Contains(errMsg, "request too large"))
if isTimeoutError && retry < maxRetries {
// Exponential backoff: 2s, 4s, 8s
backoff := time.Duration(1<<(retry+1)) * time.Second
logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
"error": err.Error(),
"retry": retry,
"backoff": backoff.String(),
})
time.Sleep(backoff)
continue
}
if isContextError && retry < maxRetries {
logger.WarnCF(
"agent",
"Context window error detected, attempting compression",
map[string]any{
"error": err.Error(),
"retry": retry,
},
)
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: "Context window exceeded. Compressing history and retrying...",
})
}
al.forceCompression(agent, opts.SessionKey)
newHistory := agent.Sessions.GetHistory(opts.SessionKey)
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
*messages = agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "",
nil, opts.Channel, opts.ChatID,
)
continue
}
break
}
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"error": err.Error(),
})
return nil, fmt.Errorf("LLM call failed after retries: %w", err)
}
return response, nil
}