Merge fix/subagent-provider-dispatch: per-agent subagent dispatch, allowlist self-spawn, response attribution

This commit is contained in:
Eric Jacksch 2026-03-19 23:16:00 -04:00
commit fe32c4cf2b
6 changed files with 205 additions and 87 deletions

View file

@ -85,13 +85,13 @@ func NewAgentLoop(
) *AgentLoop { ) *AgentLoop {
registry := NewAgentRegistry(cfg, provider) registry := NewAgentRegistry(cfg, provider)
// Register shared tools to all agents
registerSharedTools(cfg, msgBus, registry, provider)
// Set up shared fallback chain // Set up shared fallback chain
cooldown := providers.NewCooldownTracker() cooldown := providers.NewCooldownTracker()
fallbackChain := providers.NewFallbackChain(cooldown) fallbackChain := providers.NewFallbackChain(cooldown)
// Register shared tools to all agents
registerSharedTools(cfg, msgBus, registry, provider, dispatcher, fallbackChain)
// Create state manager using default agent's workspace for channel recording // Create state manager using default agent's workspace for channel recording
defaultAgent := registry.GetDefaultAgent() defaultAgent := registry.GetDefaultAgent()
var stateManager *state.Manager var stateManager *state.Manager
@ -119,6 +119,8 @@ func registerSharedTools(
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
registry *AgentRegistry, registry *AgentRegistry,
provider providers.LLMProvider, provider providers.LLMProvider,
dispatcher *providers.ProviderDispatcher,
fallbackChain *providers.FallbackChain,
) { ) {
for _, agentID := range registry.ListAgentIDs() { for _, agentID := range registry.ListAgentIDs() {
agent, ok := registry.GetAgent(agentID) agent, ok := registry.GetAgent(agentID)
@ -228,10 +230,31 @@ func registerSharedTools(
// Spawn tool with allowlist checker // Spawn tool with allowlist checker
if cfg.Tools.IsToolEnabled("spawn") { if cfg.Tools.IsToolEnabled("spawn") {
if cfg.Tools.IsToolEnabled("subagent") { if cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) currentAgentID := agentID
// Build a resolver so the subagent manager can look up any target
// agent's candidates without importing the agent package from tools.
candidateResolver := func(targetAgentID string) ([]providers.FallbackCandidate, bool) {
target, ok := registry.GetAgent(targetAgentID)
if !ok {
return nil, false
}
if len(target.Candidates) == 0 {
return nil, false
}
return target.Candidates, true
}
subagentManager := tools.NewSubagentManager(tools.SubagentManagerConfig{
Provider: provider,
DefaultModel: agent.Model,
Workspace: agent.Workspace,
Dispatcher: dispatcher,
Fallback: fallbackChain,
SelfCandidates: agent.Candidates,
CallerAgentID: currentAgentID,
CandidateResolver: candidateResolver,
})
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
spawnTool := tools.NewSpawnTool(subagentManager) spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
return registry.CanSpawnSubagent(currentAgentID, targetAgentID) return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
}) })
@ -407,8 +430,10 @@ func (al *AgentLoop) ReloadProviderAndConfig(
return fmt.Errorf("context canceled after registry creation: %w", err) return fmt.Errorf("context canceled after registry creation: %w", err)
} }
// Ensure shared tools are re-registered on the new registry // Ensure shared tools are re-registered on the new registry.
registerSharedTools(cfg, al.bus, registry, provider) // Build a fresh fallback chain for the new registry's subagent managers.
newFallbackChain := providers.NewFallbackChain(providers.NewCooldownTracker())
registerSharedTools(cfg, al.bus, registry, provider, al.dispatcher, newFallbackChain)
// Atomically swap the config and registry under write lock // Atomically swap the config and registry under write lock
// This ensures readers see a consistent pair // This ensures readers see a consistent pair

View file

@ -72,10 +72,16 @@ func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCa
label, _ := args["label"].(string) label, _ := args["label"].(string)
agentID, _ := args["agent_id"].(string) agentID, _ := args["agent_id"].(string)
// Check allowlist if targeting a specific agent // Check allowlist for both self-spawn (agentID == "") and targeted spawns.
if agentID != "" && t.allowlistCheck != nil { // For self-spawn we pass the manager's callerAgentID so that operators can
if !t.allowlistCheck(agentID) { // restrict agents to only spawning specific peers (not themselves).
return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID)) if t.allowlistCheck != nil {
checkID := agentID
if checkID == "" && t.manager != nil {
checkID = t.manager.callerAgentID
}
if checkID != "" && !t.allowlistCheck(checkID) {
return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", checkID))
} }
} }

View file

@ -8,7 +8,11 @@ import (
func TestSpawnTool_Execute_EmptyTask(t *testing.T) { func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{
Provider: provider,
DefaultModel: "test-model",
Workspace: "/tmp/test",
})
tool := NewSpawnTool(manager) tool := NewSpawnTool(manager)
ctx := context.Background() ctx := context.Background()
@ -42,7 +46,11 @@ func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
func TestSpawnTool_Execute_ValidTask(t *testing.T) { func TestSpawnTool_Execute_ValidTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{
Provider: provider,
DefaultModel: "test-model",
Workspace: "/tmp/test",
})
tool := NewSpawnTool(manager) tool := NewSpawnTool(manager)
ctx := context.Background() ctx := context.Background()

View file

@ -3,12 +3,22 @@ package tools
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// attributedContent prepends "AgentName: " to content when agentID is non-empty.
func attributedContent(agentID, content string) string {
if strings.TrimSpace(agentID) == "" {
return content
}
name := strings.ToUpper(agentID[:1]) + agentID[1:]
return "**" + name + ":** " + content
}
type SubagentTask struct { type SubagentTask struct {
ID string ID string
Task string Task string
@ -34,23 +44,97 @@ type SubagentManager struct {
hasMaxTokens bool hasMaxTokens bool
hasTemperature bool hasTemperature bool
nextID int nextID int
// Per-agent dispatch fields. When dispatcher is set, subagent LLM calls
// are routed through the dispatcher using selfCandidates (for self-spawns)
// or the target agent's candidates (resolved via candidateResolver).
dispatcher *providers.ProviderDispatcher
fallback *providers.FallbackChain
selfCandidates []providers.FallbackCandidate
callerAgentID string
candidateResolver func(agentID string) ([]providers.FallbackCandidate, bool)
} }
func NewSubagentManager( // SubagentManagerConfig holds all configuration for constructing a SubagentManager.
provider providers.LLMProvider, type SubagentManagerConfig struct {
defaultModel, workspace string, // Provider is the fallback LLM provider used when dispatcher lookup fails.
) *SubagentManager { Provider providers.LLMProvider
// DefaultModel is the model name used when no candidates are resolved.
DefaultModel string
// Workspace is the agent's working directory.
Workspace string
// Dispatcher dispatches LLM calls per-candidate. Optional.
Dispatcher *providers.ProviderDispatcher
// Fallback is the chain used when multiple candidates are configured. Optional.
Fallback *providers.FallbackChain
// SelfCandidates are the calling agent's model candidates for self-spawns.
SelfCandidates []providers.FallbackCandidate
// CallerAgentID is the ID of the agent that owns this manager (for allowlist).
CallerAgentID string
// CandidateResolver resolves model candidates for a named target agent.
// Returns false if the agent is unknown.
CandidateResolver func(agentID string) ([]providers.FallbackCandidate, bool)
}
func NewSubagentManager(cfg SubagentManagerConfig) *SubagentManager {
return &SubagentManager{ return &SubagentManager{
tasks: make(map[string]*SubagentTask), tasks: make(map[string]*SubagentTask),
provider: provider, provider: cfg.Provider,
defaultModel: defaultModel, defaultModel: cfg.DefaultModel,
workspace: workspace, workspace: cfg.Workspace,
tools: NewToolRegistry(), tools: NewToolRegistry(),
maxIterations: 10, maxIterations: 10,
nextID: 1, nextID: 1,
dispatcher: cfg.Dispatcher,
fallback: cfg.Fallback,
selfCandidates: cfg.SelfCandidates,
callerAgentID: cfg.CallerAgentID,
candidateResolver: cfg.CandidateResolver,
} }
} }
// resolveLoopConfig builds a ToolLoopConfig for the given target agent ID.
// When agentID is empty it is treated as a self-spawn and uses selfCandidates.
// When agentID is non-empty and a candidateResolver is set, it resolves the
// target agent's candidates. Falls back to provider+defaultModel when no
// dispatch metadata is available.
func (sm *SubagentManager) resolveLoopConfig(agentID string) ToolLoopConfig {
candidates := sm.selfCandidates
if agentID != "" && sm.candidateResolver != nil {
if resolved, ok := sm.candidateResolver(agentID); ok {
candidates = resolved
}
}
cfg := ToolLoopConfig{
Provider: sm.provider,
Model: sm.defaultModel,
MaxIterations: sm.maxIterations,
Tools: sm.tools,
Dispatcher: sm.dispatcher,
Fallback: sm.fallback,
Candidates: candidates,
}
// Override Model from first candidate when available.
if len(candidates) > 0 {
cfg.Model = candidates[0].Model
}
if sm.hasMaxTokens || sm.hasTemperature {
opts := map[string]any{}
if sm.hasMaxTokens {
opts["max_tokens"] = sm.maxTokens
}
if sm.hasTemperature {
opts["temperature"] = sm.temperature
}
cfg.LLMOptions = opts
}
return cfg
}
// SetLLMOptions sets max tokens and temperature for subagent LLM calls. // SetLLMOptions sets max tokens and temperature for subagent LLM calls.
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.mu.Lock() sm.mu.Lock()
@ -139,34 +223,11 @@ After completing the task, provide a clear summary of what was done.`
default: default:
} }
// Run tool loop with access to tools
sm.mu.RLock() sm.mu.RLock()
tools := sm.tools loopCfg := sm.resolveLoopConfig(task.AgentID)
maxIter := sm.maxIterations
maxTokens := sm.maxTokens
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
sm.mu.RUnlock() sm.mu.RUnlock()
var llmOptions map[string]any loopResult, err := RunToolLoop(ctx, loopCfg, messages, task.OriginChannel, task.OriginChatID)
if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{}
if hasMaxTokens {
llmOptions["max_tokens"] = maxTokens
}
if hasTemperature {
llmOptions["temperature"] = temperature
}
}
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
Provider: sm.provider,
Model: sm.defaultModel,
Tools: tools,
MaxIterations: maxIter,
LLMOptions: llmOptions,
}, messages, task.OriginChannel, task.OriginChatID)
sm.mu.Lock() sm.mu.Lock()
var result *ToolResult var result *ToolResult
@ -204,7 +265,7 @@ After completing the task, provide a clear summary of what was done.`
loopResult.Iterations, loopResult.Iterations,
loopResult.Content, loopResult.Content,
), ),
ForUser: loopResult.Content, ForUser: attributedContent(task.AgentID, loopResult.Content),
Silent: false, Silent: false,
IsError: false, IsError: false,
Async: false, Async: false,
@ -275,6 +336,7 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
label, _ := args["label"].(string) label, _ := args["label"].(string)
agentID, _ := args["agent_id"].(string)
if t.manager == nil { if t.manager == nil {
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil")) return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
@ -292,28 +354,13 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
}, },
} }
// Use RunToolLoop to execute with tools (same as async SpawnTool) // Use RunToolLoop to execute with tools (same as async SpawnTool).
// SubagentTool always performs a self-spawn (no explicit agent_id).
sm := t.manager sm := t.manager
sm.mu.RLock() sm.mu.RLock()
tools := sm.tools loopCfg := sm.resolveLoopConfig("")
maxIter := sm.maxIterations
maxTokens := sm.maxTokens
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
sm.mu.RUnlock() sm.mu.RUnlock()
var llmOptions map[string]any
if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{}
if hasMaxTokens {
llmOptions["max_tokens"] = maxTokens
}
if hasTemperature {
llmOptions["temperature"] = temperature
}
}
// Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests)
// to preserve the same defaults as the original NewSubagentTool constructor. // to preserve the same defaults as the original NewSubagentTool constructor.
channel := ToolChannel(ctx) channel := ToolChannel(ctx)
@ -325,13 +372,7 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
chatID = "direct" chatID = "direct"
} }
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ loopResult, err := RunToolLoop(ctx, loopCfg, messages, channel, chatID)
Provider: sm.provider,
Model: sm.defaultModel,
Tools: tools,
MaxIterations: maxIter,
LLMOptions: llmOptions,
}, messages, channel, chatID)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
} }
@ -353,7 +394,7 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return &ToolResult{ return &ToolResult{
ForLLM: llmContent, ForLLM: llmContent,
ForUser: userContent, ForUser: attributedContent(agentID, userContent),
Silent: false, Silent: false,
IsError: false, IsError: false,
Async: false, Async: false,

View file

@ -46,7 +46,7 @@ func (m *MockLLMProvider) GetContextWindow() int {
func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
manager.SetLLMOptions(2048, 0.6) manager.SetLLMOptions(2048, 0.6)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
@ -72,7 +72,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
// TestSubagentTool_Name verifies tool name // TestSubagentTool_Name verifies tool name
func TestSubagentTool_Name(t *testing.T) { func TestSubagentTool_Name(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
if tool.Name() != "subagent" { if tool.Name() != "subagent" {
@ -83,7 +83,7 @@ func TestSubagentTool_Name(t *testing.T) {
// TestSubagentTool_Description verifies tool description // TestSubagentTool_Description verifies tool description
func TestSubagentTool_Description(t *testing.T) { func TestSubagentTool_Description(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
desc := tool.Description() desc := tool.Description()
@ -98,7 +98,7 @@ func TestSubagentTool_Description(t *testing.T) {
// TestSubagentTool_Parameters verifies tool parameters schema // TestSubagentTool_Parameters verifies tool parameters schema
func TestSubagentTool_Parameters(t *testing.T) { func TestSubagentTool_Parameters(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
params := tool.Parameters() params := tool.Parameters()
@ -148,7 +148,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
// TestSubagentTool_Execute_Success tests successful execution // TestSubagentTool_Execute_Success tests successful execution
func TestSubagentTool_Execute_Success(t *testing.T) { func TestSubagentTool_Execute_Success(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := WithToolContext(context.Background(), "telegram", "chat-123") ctx := WithToolContext(context.Background(), "telegram", "chat-123")
@ -202,7 +202,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
// TestSubagentTool_Execute_NoLabel tests execution without label // TestSubagentTool_Execute_NoLabel tests execution without label
func TestSubagentTool_Execute_NoLabel(t *testing.T) { func TestSubagentTool_Execute_NoLabel(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
@ -225,7 +225,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
// TestSubagentTool_Execute_MissingTask tests error handling for missing task // TestSubagentTool_Execute_MissingTask tests error handling for missing task
func TestSubagentTool_Execute_MissingTask(t *testing.T) { func TestSubagentTool_Execute_MissingTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
@ -275,7 +275,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
// TestSubagentTool_Execute_ContextPassing verifies context is properly used // TestSubagentTool_Execute_ContextPassing verifies context is properly used
func TestSubagentTool_Execute_ContextPassing(t *testing.T) { func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
channel := "test-channel" channel := "test-channel"
@ -300,7 +300,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
func TestSubagentTool_ForUserTruncation(t *testing.T) { func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a mock provider that returns very long content // Create a mock provider that returns very long content
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test") manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()

View file

@ -24,6 +24,14 @@ type ToolLoopConfig struct {
Tools *ToolRegistry Tools *ToolRegistry
MaxIterations int MaxIterations int
LLMOptions map[string]any LLMOptions map[string]any
// Per-candidate dispatch: when Dispatcher and Candidates are set, RunToolLoop
// selects the provider for each LLM call through the dispatcher using the
// candidate list, falling back through candidates on error via Fallback.
// If Fallback is nil, only the first candidate is tried.
Dispatcher *providers.ProviderDispatcher
Candidates []providers.FallbackCandidate
Fallback *providers.FallbackChain
} }
// ToolLoopResult contains the result of running the tool loop. // ToolLoopResult contains the result of running the tool loop.
@ -63,8 +71,38 @@ func RunToolLoop(
if llmOpts == nil { if llmOpts == nil {
llmOpts = map[string]any{} llmOpts = map[string]any{}
} }
// 3. Call LLM // 3. Call LLM — use per-candidate dispatch when configured, else fall back
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) // to the plain Provider/Model pair.
var response *providers.LLMResponse
var err error
if config.Dispatcher != nil && len(config.Candidates) > 0 {
if config.Fallback != nil && len(config.Candidates) > 1 {
fbResult, fbErr := config.Fallback.Execute(
ctx,
config.Candidates,
func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) {
if p, perr := config.Dispatcher.Get(providerName, model); perr == nil {
return p.Chat(ctx, messages, providerToolDefs, model, llmOpts)
}
return config.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
},
)
if fbErr != nil {
err = fbErr
} else {
response = fbResult.Response
}
} else {
first := config.Candidates[0]
if p, perr := config.Dispatcher.Get(first.Provider, first.Model); perr == nil {
response, err = p.Chat(ctx, messages, providerToolDefs, first.Model, llmOpts)
} else {
response, err = config.Provider.Chat(ctx, messages, providerToolDefs, first.Model, llmOpts)
}
}
} else {
response, err = config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
}
if err != nil { if err != nil {
logger.ErrorCF("toolloop", "LLM call failed", logger.ErrorCF("toolloop", "LLM call failed",
map[string]any{ map[string]any{