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.
203 lines
5.2 KiB
Go
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
|
|
}
|