fix: eliminate data races on shared tool instances (#1080)

* fix: eliminate data races on shared tool instances

Signed-off-by: Boris Bliznioukov <blib@mail.com>

* fix: remove unused indirect dependency on github.com/gdamore/tcell/v2

Signed-off-by: Boris Bliznioukov <blib@mail.com>

* fix: reviewer comments improve context handling for tool execution and ensure defaults for non-conversation callers

Signed-off-by: Boris Bliznioukov <blib@mail.com>

---------

Signed-off-by: Boris Bliznioukov <blib@mail.com>
This commit is contained in:
Boris Bliznioukov 2026-03-05 02:57:33 +01:00 committed by GitHub
parent 204038ec60
commit aef1e8e8c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 181 additions and 252 deletions

1
go.mod
View file

@ -37,7 +37,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.13.8 // indirect
github.com/h2non/filetype v1.1.3 // indirect github.com/h2non/filetype v1.1.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect

View file

@ -543,8 +543,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
// Reset message-tool state for this round so we don't skip publishing due to a previous round. // Reset message-tool state for this round so we don't skip publishing due to a previous round.
if tool, ok := agent.Tools.Get("message"); ok { if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok { if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
mt.SetContext(msg.Channel, msg.ChatID) resetter.ResetSentInRound()
} }
} }
@ -659,10 +659,7 @@ func (al *AgentLoop) runAgentLoop(
} }
} }
// 1. Update tool contexts // 1. Build messages (skip history for heartbeat)
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
// 2. Build messages (skip history for heartbeat)
var history []providers.Message var history []providers.Message
var summary string var summary string
if !opts.NoHistory { if !opts.NoHistory {
@ -682,10 +679,10 @@ func (al *AgentLoop) runAgentLoop(
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize() maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
// 3. Save user message to session // 2. Save user message to session
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
// 4. Run LLM iteration loop // 3. Run LLM iteration loop
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
if err != nil { if err != nil {
return "", err return "", err
@ -694,21 +691,21 @@ func (al *AgentLoop) runAgentLoop(
// If last tool had ForUser content and we already sent it, we might not need to send final response // If last tool had ForUser content and we already sent it, we might not need to send final response
// This is controlled by the tool's Silent flag and ForUser content // This is controlled by the tool's Silent flag and ForUser content
// 5. Handle empty response // 4. Handle empty response
if finalContent == "" { if finalContent == "" {
finalContent = opts.DefaultResponse finalContent = opts.DefaultResponse
} }
// 6. Save final assistant message to session // 5. Save final assistant message to session
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
agent.Sessions.Save(opts.SessionKey) agent.Sessions.Save(opts.SessionKey)
// 7. Optional: summarization // 6. Optional: summarization
if opts.EnableSummary { if opts.EnableSummary {
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID) al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
} }
// 8. Optional: send response via bus // 7. Optional: send response via bus
if opts.SendResponse { if opts.SendResponse {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel, Channel: opts.Channel,
@ -717,7 +714,7 @@ func (al *AgentLoop) runAgentLoop(
}) })
} }
// 9. Log response // 8. Log response
responsePreview := utils.Truncate(finalContent, 120) responsePreview := utils.Truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
map[string]any{ map[string]any{
@ -1059,7 +1056,7 @@ func (al *AgentLoop) runLLMIteration(
"iteration": iteration, "iteration": iteration,
}) })
// Create async callback for tools that implement AsyncTool // Create async callback for tools that implement AsyncExecutor
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) { asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
if !result.Silent && result.ForUser != "" { if !result.Silent && result.ForUser != "" {
logger.InfoCF("agent", "Async tool completed, agent will handle notification", logger.InfoCF("agent", "Async tool completed, agent will handle notification",
@ -1141,26 +1138,6 @@ func (al *AgentLoop) runLLMIteration(
return finalContent, iteration, nil return finalContent, iteration, nil
} }
// updateToolContexts updates the context for tools that need channel/chatID info.
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
// Use ContextualTool interface instead of type assertions
if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(channel, chatID)
}
}
if tool, ok := agent.Tools.Get("spawn"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
}
if tool, ok := agent.Tools.Get("subagent"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
}
}
// maybeSummarize triggers summarization if the session history exceeds thresholds. // maybeSummarize triggers summarization if the session history exceeds thresholds.
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
newHistory := agent.Sessions.GetHistory(sessionKey) newHistory := agent.Sessions.GetHistory(sessionKey)

View file

@ -164,35 +164,21 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
} }
} }
// TestToolContext_Updates verifies tool context is updated with channel/chatID // TestToolContext_Updates verifies tool context helpers work correctly
func TestToolContext_Updates(t *testing.T) { func TestToolContext_Updates(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*") ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{ if got := tools.ToolChannel(ctx); got != "telegram" {
Agents: config.AgentsConfig{ t.Errorf("expected channel 'telegram', got %q", got)
Defaults: config.AgentDefaults{ }
Workspace: tmpDir, if got := tools.ToolChatID(ctx); got != "chat-42" {
Model: "test-model", t.Errorf("expected chatID 'chat-42', got %q", got)
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
} }
msgBus := bus.NewMessageBus() // Empty context returns empty strings
provider := &simpleMockProvider{response: "OK"} if got := tools.ToolChannel(context.Background()); got != "" {
_ = NewAgentLoop(cfg, msgBus, provider) t.Errorf("expected empty channel from bare context, got %q", got)
}
// Verify that ContextualTool interface is defined and can be implemented
// This test validates the interface contract exists
ctxTool := &mockContextualTool{}
// Verify the tool implements the interface correctly
var _ tools.ContextualTool = ctxTool
} }
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved // TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
@ -359,36 +345,6 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool
return tools.SilentResult("Custom tool executed") return tools.SilentResult("Custom tool executed")
} }
// mockContextualTool tracks context updates
type mockContextualTool struct {
lastChannel string
lastChatID string
}
func (m *mockContextualTool) Name() string {
return "mock_contextual"
}
func (m *mockContextualTool) Description() string {
return "Mock contextual tool"
}
func (m *mockContextualTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.SilentResult("Contextual tool executed")
}
func (m *mockContextualTool) SetContext(channel, chatID string) {
m.lastChannel = channel
m.lastChatID = chatID
}
// testHelper executes a message and returns the response // testHelper executes a message and returns the response
type testHelper struct { type testHelper struct {
al *AgentLoop al *AgentLoop

View file

@ -10,11 +10,38 @@ type Tool interface {
Execute(ctx context.Context, args map[string]any) *ToolResult Execute(ctx context.Context, args map[string]any) *ToolResult
} }
// ContextualTool is an optional interface that tools can implement // --- Request-scoped tool context (channel / chatID) ---
// to receive the current message context (channel, chatID) //
type ContextualTool interface { // Carried via context.Value so that concurrent tool calls each receive
Tool // their own immutable copy — no mutable state on singleton tool instances.
SetContext(channel, chatID string) //
// Keys are unexported pointer-typed vars — guaranteed collision-free,
// and only accessible through the helper functions below.
type toolCtxKey struct{ name string }
var (
ctxKeyChannel = &toolCtxKey{"channel"}
ctxKeyChatID = &toolCtxKey{"chatID"}
)
// WithToolContext returns a child context carrying channel and chatID.
func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
ctx = context.WithValue(ctx, ctxKeyChannel, channel)
ctx = context.WithValue(ctx, ctxKeyChatID, chatID)
return ctx
}
// ToolChannel extracts the channel from ctx, or "" if unset.
func ToolChannel(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyChannel).(string)
return v
}
// ToolChatID extracts the chatID from ctx, or "" if unset.
func ToolChatID(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyChatID).(string)
return v
} }
// AsyncCallback is a function type that async tools use to notify completion. // AsyncCallback is a function type that async tools use to notify completion.
@ -22,51 +49,36 @@ type ContextualTool interface {
// //
// The ctx parameter allows the callback to be canceled if the agent is shutting down. // The ctx parameter allows the callback to be canceled if the agent is shutting down.
// The result parameter contains the tool's execution result. // The result parameter contains the tool's execution result.
//
// Example usage in an async tool:
//
// func (t *MyAsyncTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
// // Start async work in background
// go func() {
// result := doAsyncWork()
// if t.callback != nil {
// t.callback(ctx, result)
// }
// }()
// return AsyncResult("Async task started")
// }
type AsyncCallback func(ctx context.Context, result *ToolResult) type AsyncCallback func(ctx context.Context, result *ToolResult)
// AsyncTool is an optional interface that tools can implement to support // AsyncExecutor is an optional interface that tools can implement to support
// asynchronous execution with completion callbacks. // asynchronous execution with completion callbacks.
// //
// Async tools return immediately with an AsyncResult, then notify completion // Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor
// via the callback set by SetCallback. // receives the callback as a parameter of ExecuteAsync. This eliminates the
// data race where concurrent calls could overwrite each other's callbacks
// on a shared tool instance.
// //
// This is useful for: // This is useful for:
// - Long-running operations that shouldn't block the agent loop // - Long-running operations that shouldn't block the agent loop
// - Subagent spawns that complete independently // - Subagent spawns that complete independently
// - Background tasks that need to report results later // - Background tasks that need to report results later
// //
// Example: // Example:
// //
// type SpawnTool struct { // func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
// callback AsyncCallback // go func() {
// } // result := t.runSubagent(ctx, args)
// // if cb != nil { cb(ctx, result) }
// func (t *SpawnTool) SetCallback(cb AsyncCallback) { // }()
// t.callback = cb
// }
//
// func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
// go t.runSubagent(ctx, args)
// return AsyncResult("Subagent spawned, will report back") // return AsyncResult("Subagent spawned, will report back")
// } // }
type AsyncTool interface { type AsyncExecutor interface {
Tool Tool
// SetCallback registers a callback function to be invoked when the async operation completes. // ExecuteAsync runs the tool asynchronously. The callback cb will be
// The callback will be called from a goroutine and should handle thread-safety if needed. // invoked (possibly from another goroutine) when the async operation
SetCallback(cb AsyncCallback) // completes. cb is guaranteed to be non-nil by the caller (registry).
ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult
} }
func ToolToSchema(tool Tool) map[string]any { func ToolToSchema(tool Tool) map[string]any {

View file

@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -24,9 +23,6 @@ type CronTool struct {
executor JobExecutor executor JobExecutor
msgBus *bus.MessageBus msgBus *bus.MessageBus
execTool *ExecTool execTool *ExecTool
channel string
chatID string
mu sync.RWMutex
} }
// NewCronTool creates a new CronTool // NewCronTool creates a new CronTool
@ -102,14 +98,6 @@ func (t *CronTool) Parameters() map[string]any {
} }
} }
// SetContext sets the current session context for job creation
func (t *CronTool) SetContext(channel, chatID string) {
t.mu.Lock()
defer t.mu.Unlock()
t.channel = channel
t.chatID = chatID
}
// Execute runs the tool with the given arguments // Execute runs the tool with the given arguments
func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, ok := args["action"].(string) action, ok := args["action"].(string)
@ -119,7 +107,7 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult
switch action { switch action {
case "add": case "add":
return t.addJob(args) return t.addJob(ctx, args)
case "list": case "list":
return t.listJobs() return t.listJobs()
case "remove": case "remove":
@ -133,11 +121,9 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult
} }
} }
func (t *CronTool) addJob(args map[string]any) *ToolResult { func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult {
t.mu.RLock() channel := ToolChannel(ctx)
channel := t.channel chatID := ToolChatID(ctx)
chatID := t.chatID
t.mu.RUnlock()
if channel == "" || chatID == "" { if channel == "" || chatID == "" {
return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.") return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.")

View file

@ -9,10 +9,8 @@ import (
type SendCallback func(channel, chatID, content string) error type SendCallback func(channel, chatID, content string) error
type MessageTool struct { type MessageTool struct {
sendCallback SendCallback sendCallback SendCallback
defaultChannel string sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
defaultChatID string
sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
} }
func NewMessageTool() *MessageTool { func NewMessageTool() *MessageTool {
@ -48,10 +46,10 @@ func (t *MessageTool) Parameters() map[string]any {
} }
} }
func (t *MessageTool) SetContext(channel, chatID string) { // ResetSentInRound resets the per-round send tracker.
t.defaultChannel = channel // Called by the agent loop at the start of each inbound message processing round.
t.defaultChatID = chatID func (t *MessageTool) ResetSentInRound() {
t.sentInRound.Store(false) // Reset send tracking for new processing round t.sentInRound.Store(false)
} }
// HasSentInRound returns true if the message tool sent a message during the current round. // HasSentInRound returns true if the message tool sent a message during the current round.
@ -73,10 +71,10 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
chatID, _ := args["chat_id"].(string) chatID, _ := args["chat_id"].(string)
if channel == "" { if channel == "" {
channel = t.defaultChannel channel = ToolChannel(ctx)
} }
if chatID == "" { if chatID == "" {
chatID = t.defaultChatID chatID = ToolChatID(ctx)
} }
if channel == "" || chatID == "" { if channel == "" || chatID == "" {

View file

@ -8,7 +8,6 @@ import (
func TestMessageTool_Execute_Success(t *testing.T) { func TestMessageTool_Execute_Success(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("test-channel", "test-chat-id")
var sentChannel, sentChatID, sentContent string var sentChannel, sentChatID, sentContent string
tool.SetSendCallback(func(channel, chatID, content string) error { tool.SetSendCallback(func(channel, chatID, content string) error {
@ -18,7 +17,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
return nil return nil
}) })
ctx := context.Background() ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
args := map[string]any{ args := map[string]any{
"content": "Hello, world!", "content": "Hello, world!",
} }
@ -60,7 +59,6 @@ func TestMessageTool_Execute_Success(t *testing.T) {
func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("default-channel", "default-chat-id")
var sentChannel, sentChatID string var sentChannel, sentChatID string
tool.SetSendCallback(func(channel, chatID, content string) error { tool.SetSendCallback(func(channel, chatID, content string) error {
@ -69,7 +67,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
return nil return nil
}) })
ctx := context.Background() ctx := WithToolContext(context.Background(), "default-channel", "default-chat-id")
args := map[string]any{ args := map[string]any{
"content": "Test message", "content": "Test message",
"channel": "custom-channel", "channel": "custom-channel",
@ -96,14 +94,13 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
func TestMessageTool_Execute_SendFailure(t *testing.T) { func TestMessageTool_Execute_SendFailure(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("test-channel", "test-chat-id")
sendErr := errors.New("network error") sendErr := errors.New("network error")
tool.SetSendCallback(func(channel, chatID, content string) error { tool.SetSendCallback(func(channel, chatID, content string) error {
return sendErr return sendErr
}) })
ctx := context.Background() ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
args := map[string]any{ args := map[string]any{
"content": "Test message", "content": "Test message",
} }
@ -133,9 +130,8 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
func TestMessageTool_Execute_MissingContent(t *testing.T) { func TestMessageTool_Execute_MissingContent(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("test-channel", "test-chat-id")
ctx := context.Background() ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
args := map[string]any{} // content missing args := map[string]any{} // content missing
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
@ -151,7 +147,7 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) {
func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
// No SetContext called, so defaultChannel and defaultChatID are empty // No WithToolContext — channel/chatID are empty
tool.SetSendCallback(func(channel, chatID, content string) error { tool.SetSendCallback(func(channel, chatID, content string) error {
return nil return nil
@ -175,10 +171,9 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
func TestMessageTool_Execute_NotConfigured(t *testing.T) { func TestMessageTool_Execute_NotConfigured(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("test-channel", "test-chat-id")
// No SetSendCallback called // No SetSendCallback called
ctx := context.Background() ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
args := map[string]any{ args := map[string]any{
"content": "Test message", "content": "Test message",
} }

View file

@ -45,8 +45,9 @@ func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string
} }
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback. // ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
// If the tool implements AsyncTool and a non-nil callback is provided, // If the tool implements AsyncExecutor and a non-nil callback is provided,
// the callback will be set on the tool before execution. // ExecuteAsync is called instead of Execute — the callback is a parameter,
// never stored as mutable state on the tool.
func (r *ToolRegistry) ExecuteWithContext( func (r *ToolRegistry) ExecuteWithContext(
ctx context.Context, ctx context.Context,
name string, name string,
@ -69,22 +70,23 @@ func (r *ToolRegistry) ExecuteWithContext(
return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found")) return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found"))
} }
// If tool implements ContextualTool, set context // Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx).
if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" { // Always inject — tools validate what they require.
contextualTool.SetContext(channel, chatID) ctx = WithToolContext(ctx, channel, chatID)
}
// If tool implements AsyncTool and callback is provided, set callback // If tool implements AsyncExecutor and callback is provided, use ExecuteAsync.
if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil { // The callback is a call parameter, not mutable state on the tool instance.
asyncTool.SetCallback(asyncCallback) var result *ToolResult
logger.DebugCF("tool", "Async callback injected", start := time.Now()
if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil {
logger.DebugCF("tool", "Executing async tool via ExecuteAsync",
map[string]any{ map[string]any{
"tool": name, "tool": name,
}) })
result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
} else {
result = tool.Execute(ctx, args)
} }
start := time.Now()
result := tool.Execute(ctx, args)
duration := time.Since(start) duration := time.Since(start)
// Log based on result type // Log based on result type

View file

@ -25,24 +25,24 @@ func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolRes
return m.result return m.result
} }
type mockCtxTool struct { type mockContextAwareTool struct {
mockRegistryTool mockRegistryTool
channel string lastCtx context.Context
chatID string
} }
func (m *mockCtxTool) SetContext(channel, chatID string) { func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *ToolResult {
m.channel = channel m.lastCtx = ctx
m.chatID = chatID return m.result
} }
type mockAsyncRegistryTool struct { type mockAsyncRegistryTool struct {
mockRegistryTool mockRegistryTool
cb AsyncCallback lastCB AsyncCallback
} }
func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) { func (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
m.cb = cb m.lastCB = cb
return m.result
} }
// --- helpers --- // --- helpers ---
@ -136,34 +136,44 @@ func TestToolRegistry_Execute_NotFound(t *testing.T) {
} }
} }
func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) { func TestToolRegistry_ExecuteWithContext_InjectsToolContext(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
ct := &mockCtxTool{ ct := &mockContextAwareTool{
mockRegistryTool: *newMockTool("ctx_tool", "needs context"), mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
} }
r.Register(ct) r.Register(ct)
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
if ct.channel != "telegram" { if ct.lastCtx == nil {
t.Errorf("expected channel 'telegram', got %q", ct.channel) t.Fatal("expected Execute to be called")
} }
if ct.chatID != "chat-42" { if got := ToolChannel(ct.lastCtx); got != "telegram" {
t.Errorf("expected chatID 'chat-42', got %q", ct.chatID) t.Errorf("expected channel 'telegram', got %q", got)
}
if got := ToolChatID(ct.lastCtx); got != "chat-42" {
t.Errorf("expected chatID 'chat-42', got %q", got)
} }
} }
func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) { func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
ct := &mockCtxTool{ ct := &mockContextAwareTool{
mockRegistryTool: *newMockTool("ctx_tool", "needs context"), mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
} }
r.Register(ct) r.Register(ct)
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
if ct.channel != "" || ct.chatID != "" { if ct.lastCtx == nil {
t.Error("SetContext should not be called with empty channel/chatID") t.Fatal("expected Execute to be called")
}
// Empty values are still injected; tools decide what to do with them.
if got := ToolChannel(ct.lastCtx); got != "" {
t.Errorf("expected empty channel, got %q", got)
}
if got := ToolChatID(ct.lastCtx); got != "" {
t.Errorf("expected empty chatID, got %q", got)
} }
} }
@ -179,14 +189,14 @@ func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
cb := func(_ context.Context, _ *ToolResult) { called = true } cb := func(_ context.Context, _ *ToolResult) { called = true }
result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb) result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
if at.cb == nil { if at.lastCB == nil {
t.Error("expected SetCallback to have been called") t.Error("expected ExecuteAsync to have received a callback")
} }
if !result.Async { if !result.Async {
t.Error("expected async result") t.Error("expected async result")
} }
at.cb(context.Background(), SilentResult("done")) at.lastCB(context.Background(), SilentResult("done"))
if !called { if !called {
t.Error("expected callback to be invoked") t.Error("expected callback to be invoked")
} }

View file

@ -8,25 +8,18 @@ import (
type SpawnTool struct { type SpawnTool struct {
manager *SubagentManager manager *SubagentManager
originChannel string
originChatID string
allowlistCheck func(targetAgentID string) bool allowlistCheck func(targetAgentID string) bool
callback AsyncCallback // For async completion notification
} }
// Compile-time check: SpawnTool implements AsyncExecutor.
var _ AsyncExecutor = (*SpawnTool)(nil)
func NewSpawnTool(manager *SubagentManager) *SpawnTool { func NewSpawnTool(manager *SubagentManager) *SpawnTool {
return &SpawnTool{ return &SpawnTool{
manager: manager, manager: manager,
originChannel: "cli",
originChatID: "direct",
} }
} }
// SetCallback implements AsyncTool interface for async completion notification
func (t *SpawnTool) SetCallback(cb AsyncCallback) {
t.callback = cb
}
func (t *SpawnTool) Name() string { func (t *SpawnTool) Name() string {
return "spawn" return "spawn"
} }
@ -56,16 +49,21 @@ func (t *SpawnTool) Parameters() map[string]any {
} }
} }
func (t *SpawnTool) SetContext(channel, chatID string) {
t.originChannel = channel
t.originChatID = chatID
}
func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) { func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
t.allowlistCheck = check t.allowlistCheck = check
} }
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
return t.execute(ctx, args, nil)
}
// ExecuteAsync implements AsyncExecutor. The callback is passed through to the
// subagent manager as a call parameter — never stored on the SpawnTool instance.
func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
return t.execute(ctx, args, cb)
}
func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
task, ok := args["task"].(string) task, ok := args["task"].(string)
if !ok || strings.TrimSpace(task) == "" { if !ok || strings.TrimSpace(task) == "" {
return ErrorResult("task is required and must be a non-empty string") return ErrorResult("task is required and must be a non-empty string")
@ -85,8 +83,20 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul
return ErrorResult("Subagent manager not configured") return ErrorResult("Subagent manager not configured")
} }
// Read channel/chatID from context (injected by registry).
// Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests)
// to preserve the same defaults as the original NewSpawnTool constructor.
channel := ToolChannel(ctx)
if channel == "" {
channel = "cli"
}
chatID := ToolChatID(ctx)
if chatID == "" {
chatID = "direct"
}
// Pass callback to manager for async completion notification // Pass callback to manager for async completion notification
result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, t.callback) result, err := t.manager.Spawn(ctx, task, label, agentID, channel, chatID, cb)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err)) return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
} }

View file

@ -252,16 +252,12 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask {
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion // Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
// and returns the result directly in the ToolResult. // and returns the result directly in the ToolResult.
type SubagentTool struct { type SubagentTool struct {
manager *SubagentManager manager *SubagentManager
originChannel string
originChatID string
} }
func NewSubagentTool(manager *SubagentManager) *SubagentTool { func NewSubagentTool(manager *SubagentManager) *SubagentTool {
return &SubagentTool{ return &SubagentTool{
manager: manager, manager: manager,
originChannel: "cli",
originChatID: "direct",
} }
} }
@ -290,11 +286,6 @@ func (t *SubagentTool) Parameters() map[string]any {
} }
} }
func (t *SubagentTool) SetContext(channel, chatID string) {
t.originChannel = channel
t.originChatID = chatID
}
func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string) task, ok := args["task"].(string)
if !ok { if !ok {
@ -341,13 +332,24 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
} }
// Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests)
// to preserve the same defaults as the original NewSubagentTool constructor.
channel := ToolChannel(ctx)
if channel == "" {
channel = "cli"
}
chatID := ToolChatID(ctx)
if chatID == "" {
chatID = "direct"
}
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
Provider: sm.provider, Provider: sm.provider,
Model: sm.defaultModel, Model: sm.defaultModel,
Tools: tools, Tools: tools,
MaxIterations: maxIter, MaxIterations: maxIter,
LLMOptions: llmOptions, LLMOptions: llmOptions,
}, messages, t.originChannel, t.originChatID) }, 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)
} }

View file

@ -50,9 +50,8 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
manager.SetLLMOptions(2048, 0.6) manager.SetLLMOptions(2048, 0.6)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
tool.SetContext("cli", "direct")
ctx := context.Background() ctx := WithToolContext(context.Background(), "cli", "direct")
args := map[string]any{"task": "Do something"} args := map[string]any{"task": "Do something"}
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
@ -147,28 +146,14 @@ func TestSubagentTool_Parameters(t *testing.T) {
} }
} }
// TestSubagentTool_SetContext verifies context setting
func TestSubagentTool_SetContext(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager)
tool.SetContext("test-channel", "test-chat")
// Verify context is set (we can't directly access private fields,
// but we can verify it doesn't crash)
// The actual context usage is tested in Execute tests
}
// 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{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
tool.SetContext("telegram", "chat-123")
ctx := context.Background() ctx := WithToolContext(context.Background(), "telegram", "chat-123")
args := map[string]any{ args := map[string]any{
"task": "Write a haiku about coding", "task": "Write a haiku about coding",
"label": "haiku-task", "label": "haiku-task",
@ -297,12 +282,9 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
// Set context
channel := "test-channel" channel := "test-channel"
chatID := "test-chat" chatID := "test-chat"
tool.SetContext(channel, chatID) ctx := WithToolContext(context.Background(), channel, chatID)
ctx := context.Background()
args := map[string]any{ args := map[string]any{
"task": "Test context passing", "task": "Test context passing",
} }