picoclaw/pkg/tools/toolloop.go
Dmitrii Balabanov 81d606766a Add reaction tool with typing/placeholder cleanup
Introduces a new `reaction` tool that lets the LLM add an emoji reaction
to a Telegram message instead of sending a text reply. When the reaction
tool (or message tool) handles a turn, CleanupState is now called on the
channel manager so typing indicators and placeholder messages are properly
removed even though no outbound message is published via the bus.
2026-03-09 19:38:35 +02:00

203 lines
5.2 KiB
Go

// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package tools
import (
"context"
"encoding/json"
"fmt"
"sync"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/utils"
)
// ToolLoopConfig configures the tool execution loop.
type ToolLoopConfig struct {
Provider providers.LLMProvider
Model string
Tools *ToolRegistry
MaxIterations int
LLMOptions map[string]any
}
// ToolLoopResult contains the result of running the tool loop.
type ToolLoopResult struct {
Content string
Iterations int
}
// RunToolLoop executes the LLM + tool call iteration loop.
// This is the core agent logic that can be reused by both main agent and subagents.
func RunToolLoop(
ctx context.Context,
config ToolLoopConfig,
messages []providers.Message,
channel, chatID string,
) (*ToolLoopResult, error) {
iteration := 0
var finalContent string
for iteration < config.MaxIterations {
iteration++
logger.DebugCF("toolloop", "LLM iteration",
map[string]any{
"iteration": iteration,
"max": config.MaxIterations,
})
// 1. Build tool definitions
var providerToolDefs []providers.ToolDefinition
if config.Tools != nil {
providerToolDefs = config.Tools.ToProviderDefsWithContext(ctx, channel, chatID)
}
// 2. Set default LLM options
llmOpts := config.LLMOptions
if llmOpts == nil {
llmOpts = map[string]any{}
}
// 3. Call LLM
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
if err != nil {
logger.ErrorCF("toolloop", "LLM call failed",
map[string]any{
"iteration": iteration,
"error": err.Error(),
})
return nil, fmt.Errorf("LLM call failed: %w", err)
}
// 4. If no tool calls, we're done
if len(response.ToolCalls) == 0 {
finalContent = response.Content
logger.InfoCF("toolloop", "LLM response without tool calls (direct answer)",
map[string]any{
"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))
}
// 5. Log tool calls
toolNames := make([]string, 0, len(normalizedToolCalls))
for _, tc := range normalizedToolCalls {
toolNames = append(toolNames, tc.Name)
}
logger.InfoCF("toolloop", "LLM requested tool calls",
map[string]any{
"tools": toolNames,
"count": len(normalizedToolCalls),
"iteration": iteration,
})
// 6. Build assistant message with tool calls
assistantMsg := providers.Message{
Role: "assistant",
Content: response.Content,
}
for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID,
Type: "function",
Name: tc.Name,
Arguments: tc.Arguments,
Function: &providers.FunctionCall{
Name: tc.Name,
Arguments: string(argumentsJSON),
},
})
}
messages = append(messages, assistantMsg)
// 7. Execute tool calls, preserving model order for tools that require it.
type indexedResult struct {
result *ToolResult
tc providers.ToolCall
}
results := make([]indexedResult, len(normalizedToolCalls))
executeToolCall := func(idx int, tc providers.ToolCall) {
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
map[string]any{
"tool": tc.Name,
"iteration": iteration,
})
var toolResult *ToolResult
if config.Tools != nil {
toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil)
} else {
toolResult = ErrorResult("No tools available")
}
results[idx].result = toolResult
}
executeParallelBatch := func(start, end int) {
var wg sync.WaitGroup
for i := start; i < end; i++ {
tc := normalizedToolCalls[i]
wg.Add(1)
go func(idx int, tc providers.ToolCall) {
defer wg.Done()
executeToolCall(idx, tc)
}(i, tc)
}
wg.Wait()
}
batchStart := -1
for i, tc := range normalizedToolCalls {
results[i].tc = tc
if config.Tools != nil && config.Tools.ExecutesSequentially(tc.Name) {
if batchStart != -1 {
executeParallelBatch(batchStart, i)
batchStart = -1
}
executeToolCall(i, tc)
continue
}
if batchStart == -1 {
batchStart = i
}
}
if batchStart != -1 {
executeParallelBatch(batchStart, len(normalizedToolCalls))
}
// Append results in original order
for _, r := range results {
contentForLLM := r.result.ForLLM
if contentForLLM == "" && r.result.Err != nil {
contentForLLM = r.result.Err.Error()
}
messages = append(messages, providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: r.tc.ID,
})
}
}
return &ToolLoopResult{
Content: finalContent,
Iterations: iteration,
}, nil
}