diff --git a/pkg/tools/base.go b/pkg/tools/base.go index 57209a1a6..468de5927 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -167,6 +167,14 @@ type AsyncTool interface { SetCallback(cb AsyncCallback) } +// ExampleProvider is an optional interface for tools that want to include +// input examples in their schema definition. This follows Anthropic's Tool Use +// Examples pattern — concrete usage samples that teach the LLM when to include +// optional parameters, which combinations make sense, and API conventions. +type ExampleProvider interface { + InputExamples() []map[string]interface{} +} + // ResourceProvider is an optional interface that tools can implement to declare // resources they need loaded before execution (schemas, examples, docs, configs). // @@ -189,12 +197,18 @@ type ResourceProvider interface { } func ToolToSchema(tool Tool) map[string]interface{} { + fn := map[string]interface{}{ + "name": tool.Name(), + "description": tool.Description(), + "parameters": tool.Parameters(), + } + if ep, ok := tool.(ExampleProvider); ok { + if examples := ep.InputExamples(); len(examples) > 0 { + fn["input_examples"] = examples + } + } return map[string]interface{}{ - "type": "function", - "function": map[string]interface{}{ - "name": tool.Name(), - "description": tool.Description(), - "parameters": tool.Parameters(), - }, + "type": "function", + "function": fn, } } diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index fed8186fa..d78f5cc0a 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -10,10 +10,10 @@ import ( ) type ToolRegistry struct { - tools map[string]Tool - mu sync.RWMutex - progressiveDisclosure bool - // gatewayTools are always visible even in progressive mode + tools map[string]Tool + mu sync.RWMutex + // gatewayTools are always visible to the LLM; all other tools are + // discovered via tool_search + tool_call (progressive disclosure). gatewayTools map[string]bool } @@ -24,14 +24,9 @@ func NewToolRegistry() *ToolRegistry { } } -// SetProgressiveDisclosure enables or disables progressive disclosure mode. -// When enabled, GetVisibleDefinitions returns only gateway tools (tool_search, -// tool_call, and any explicitly marked tools). The agent discovers others via tool_search. -func (r *ToolRegistry) SetProgressiveDisclosure(enabled bool) { - r.mu.Lock() - defer r.mu.Unlock() - r.progressiveDisclosure = enabled -} +// SetProgressiveDisclosure is a no-op retained for backward compatibility. +// Progressive disclosure is always enabled. +func (r *ToolRegistry) SetProgressiveDisclosure(_ bool) {} // MarkGateway marks a tool name as always visible in progressive disclosure mode. func (r *ToolRegistry) MarkGateway(name string) { @@ -52,16 +47,11 @@ func (r *ToolRegistry) RegisterMetaTools() { } // GetVisibleDefinitions returns tool definitions visible to the LLM. -// In progressive disclosure mode, only gateway tools are returned. -// In full mode, all tools are returned (same as GetDefinitions). +// Only gateway tools are returned; the agent discovers others via tool_search. func (r *ToolRegistry) GetVisibleDefinitions() []map[string]interface{} { r.mu.RLock() defer r.mu.RUnlock() - if !r.progressiveDisclosure { - return r.getDefinitionsLocked() - } - definitions := make([]map[string]interface{}, 0) for _, tool := range r.tools { if r.gatewayTools[tool.Name()] { @@ -71,14 +61,6 @@ func (r *ToolRegistry) GetVisibleDefinitions() []map[string]interface{} { return definitions } -func (r *ToolRegistry) getDefinitionsLocked() []map[string]interface{} { - definitions := make([]map[string]interface{}, 0, len(r.tools)) - for _, tool := range r.tools { - definitions = append(definitions, ToolToSchema(tool)) - } - return definitions -} - func (r *ToolRegistry) Register(tool Tool) { r.mu.Lock() defer r.mu.Unlock() @@ -182,21 +164,11 @@ func (r *ToolRegistry) List() []string { return names } -// ListVisible returns tool names visible to the LLM. -// In progressive disclosure mode, only gateway tools are returned. -// In full mode, all tools are returned. +// ListVisible returns tool names visible to the LLM (gateway tools only). func (r *ToolRegistry) ListVisible() []string { r.mu.RLock() defer r.mu.RUnlock() - if !r.progressiveDisclosure { - names := make([]string, 0, len(r.tools)) - for name := range r.tools { - names = append(names, name) - } - return names - } - names := make([]string, 0, len(r.gatewayTools)) for name := range r.gatewayTools { if _, ok := r.tools[name]; ok { diff --git a/pkg/tools/registry_progressive_test.go b/pkg/tools/registry_progressive_test.go index bbf3c0145..5ad28b82e 100644 --- a/pkg/tools/registry_progressive_test.go +++ b/pkg/tools/registry_progressive_test.go @@ -6,31 +6,16 @@ import ( "testing" ) -func TestProgressiveDisclosure_Disabled_AllToolsVisible(t *testing.T) { - r := NewToolRegistry() - r.Register(&stubTool{name: "read_file", desc: "Read"}) - r.Register(&stubTool{name: "write_file", desc: "Write"}) - r.RegisterMetaTools() - - visible := r.ListVisible() - // Should include all 4: read_file, write_file, tool_search, tool_call - if len(visible) != 4 { - t.Errorf("expected 4 visible tools, got %d: %v", len(visible), visible) - } -} - -func TestProgressiveDisclosure_Enabled_OnlyGateway(t *testing.T) { +func TestProgressiveDisclosure_OnlyGatewayVisible(t *testing.T) { r := NewToolRegistry() r.Register(&stubTool{name: "read_file", desc: "Read"}) r.Register(&stubTool{name: "write_file", desc: "Write"}) r.Register(&stubTool{name: "web_search", desc: "Search"}) r.RegisterMetaTools() - r.SetProgressiveDisclosure(true) visible := r.ListVisible() sort.Strings(visible) - // Only gateway tools expected := []string{"tool_call", "tool_search"} if len(visible) != len(expected) { t.Fatalf("expected %v, got %v", expected, visible) @@ -48,7 +33,6 @@ func TestProgressiveDisclosure_MarkGateway(t *testing.T) { r.Register(&stubTool{name: "memory", desc: "Memory tool"}) r.RegisterMetaTools() r.MarkGateway("memory") - r.SetProgressiveDisclosure(true) visible := r.ListVisible() sort.Strings(visible) @@ -70,17 +54,9 @@ func TestProgressiveDisclosure_GetVisibleDefinitions(t *testing.T) { r.Register(&stubTool{name: "write_file", desc: "Write"}) r.RegisterMetaTools() - // Full mode - allDefs := r.GetVisibleDefinitions() - if len(allDefs) != 4 { - t.Errorf("full mode: expected 4 definitions, got %d", len(allDefs)) - } - - // Progressive mode - r.SetProgressiveDisclosure(true) gatewayDefs := r.GetVisibleDefinitions() if len(gatewayDefs) != 2 { - t.Errorf("progressive mode: expected 2 definitions, got %d", len(gatewayDefs)) + t.Errorf("expected 2 gateway definitions, got %d", len(gatewayDefs)) } } @@ -88,9 +64,7 @@ func TestProgressiveDisclosure_AllToolsStillDispatchable(t *testing.T) { r := NewToolRegistry() r.Register(&stubTool{name: "read_file", desc: "Read"}) r.RegisterMetaTools() - r.SetProgressiveDisclosure(true) - // read_file is hidden from LLM but still in registry tool, ok := r.Get("read_file") if !ok { t.Fatal("read_file should still exist in registry") @@ -99,7 +73,6 @@ func TestProgressiveDisclosure_AllToolsStillDispatchable(t *testing.T) { t.Errorf("expected read_file, got %s", tool.Name()) } - // tool_call should still dispatch to it tc, _ := r.Get("tool_call") result := tc.Execute(context.TODO(), map[string]interface{}{ "tool_name": "read_file", @@ -115,9 +88,7 @@ func TestProgressiveDisclosure_SearchFindsHiddenTools(t *testing.T) { r.Register(&stubTool{name: "read_file", desc: "Read a file from filesystem"}) r.Register(&stubTool{name: "write_file", desc: "Write to a file"}) r.RegisterMetaTools() - r.SetProgressiveDisclosure(true) - // Even though read_file is hidden from Fantasy, tool_search should find it ts, _ := r.Get("tool_search") result := ts.Execute(context.TODO(), map[string]interface{}{"query": "read"}) @@ -125,40 +96,15 @@ func TestProgressiveDisclosure_SearchFindsHiddenTools(t *testing.T) { t.Fatalf("unexpected error: %s", result.ForLLM) } - // Result should contain read_file if result.ForLLM == "" { t.Fatal("expected non-empty result") } } -func TestProgressiveDisclosure_ToggleRuntime(t *testing.T) { - r := NewToolRegistry() - r.Register(&stubTool{name: "alpha", desc: "A tool"}) - r.RegisterMetaTools() - - // Start in full mode - if len(r.ListVisible()) != 3 { - t.Fatal("expected 3 visible in full mode") - } - - // Switch to progressive - r.SetProgressiveDisclosure(true) - if len(r.ListVisible()) != 2 { - t.Errorf("expected 2 visible in progressive mode, got %d", len(r.ListVisible())) - } - - // Switch back - r.SetProgressiveDisclosure(false) - if len(r.ListVisible()) != 3 { - t.Errorf("expected 3 visible in full mode again, got %d", len(r.ListVisible())) - } -} - func TestProgressiveDisclosure_MarkNonexistentGateway(t *testing.T) { r := NewToolRegistry() r.RegisterMetaTools() r.MarkGateway("nonexistent") - r.SetProgressiveDisclosure(true) visible := r.ListVisible() for _, name := range visible { diff --git a/pkg/tools/search.go b/pkg/tools/search.go index 8bb50ef57..ff83f2d6f 100644 --- a/pkg/tools/search.go +++ b/pkg/tools/search.go @@ -7,12 +7,15 @@ import ( "strings" jsonv2 "github.com/go-json-experiment/json" + "github.com/sipeed/picoclaw/pkg/skills" ) -// ToolSearchTool implements tool discovery via fuzzy search over the registry. -// The agent can query for tools by keyword and get back ranked summaries. +// ToolSearchTool implements unified discovery via fuzzy search over the registry +// and (optionally) the skills library. Returns ranked results for both tools +// and skills so the agent has a single entry point for capability discovery. type ToolSearchTool struct { - registry *ToolRegistry + registry *ToolRegistry + skillsLoader *skills.SkillsLoader } // NewToolSearchTool creates a tool that searches the registry. @@ -20,10 +23,15 @@ func NewToolSearchTool(registry *ToolRegistry) *ToolSearchTool { return &ToolSearchTool{registry: registry} } +// SetSkillsLoader enables unified search across tools and skills. +func (t *ToolSearchTool) SetSkillsLoader(sl *skills.SkillsLoader) { + t.skillsLoader = sl +} + func (t *ToolSearchTool) Name() string { return "tool_search" } func (t *ToolSearchTool) Description() string { - return "Search for available tools by keyword. Returns tool names, descriptions, and parameter summaries. Use this to discover what tools are available before calling them with tool_call." + return "Search for available tools and skills by keyword. Returns names, descriptions, and kind (tool or skill). Use this to discover capabilities before invoking them with tool_call (tools) or skill_read (skills)." } func (t *ToolSearchTool) Parameters() map[string]interface{} { @@ -40,9 +48,12 @@ func (t *ToolSearchTool) Parameters() map[string]interface{} { } type toolSearchResult struct { - Name string `json:"name"` - Description string `json:"description"` - Score int `json:"score"` + Name string `json:"name"` + Description string `json:"description"` + Kind string `json:"kind"` + Score int `json:"score,omitempty"` + Tags []string `json:"tags,omitempty"` + Domain string `json:"domain,omitempty"` } func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{}) *ToolResult { @@ -54,39 +65,54 @@ func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{}) queryLower := strings.ToLower(query) queryTerms := strings.Fields(queryLower) - t.registry.mu.RLock() - defer t.registry.mu.RUnlock() - var results []toolSearchResult + // Search tools + t.registry.mu.RLock() for _, tool := range t.registry.tools { - // Skip meta-tools from results if tool.Name() == "tool_search" || tool.Name() == "tool_call" { continue } - score := fuzzyScore(tool.Name(), tool.Description(), queryTerms) if score > 0 { results = append(results, toolSearchResult{ Name: tool.Name(), Description: tool.Description(), + Kind: "tool", Score: score, }) } } + t.registry.mu.RUnlock() + + // Search skills (unified discovery) + if t.skillsLoader != nil { + for _, si := range t.skillsLoader.ListSkills() { + combined := si.Name + " " + si.Description + " " + si.Domain + " " + strings.Join(si.Tags, " ") + score := fuzzyScore(si.Name, combined, queryTerms) + if score > 0 { + results = append(results, toolSearchResult{ + Name: si.Name, + Description: si.Description, + Kind: "skill", + Score: score, + Tags: si.Tags, + Domain: si.Domain, + }) + } + } + } - // Sort by score descending sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) - // Limit to top 10 - if len(results) > 10 { - results = results[:10] + if len(results) > 15 { + results = results[:15] } if len(results) == 0 { - return &ToolResult{ForLLM: fmt.Sprintf("No tools match query: %q. Try a broader search or use tool_search with no query to list all.", query)} + return &ToolResult{ForLLM: fmt.Sprintf("No tools or skills match query: %q. Try a broader search or use tool_search with no query to list all.", query)} } b, _ := jsonv2.Marshal(results) @@ -94,10 +120,9 @@ func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{}) } func (t *ToolSearchTool) listAll() *ToolResult { - t.registry.mu.RLock() - defer t.registry.mu.RUnlock() - var results []toolSearchResult + + t.registry.mu.RLock() for _, tool := range t.registry.tools { if tool.Name() == "tool_search" || tool.Name() == "tool_call" { continue @@ -105,8 +130,22 @@ func (t *ToolSearchTool) listAll() *ToolResult { results = append(results, toolSearchResult{ Name: tool.Name(), Description: tool.Description(), + Kind: "tool", }) } + t.registry.mu.RUnlock() + + if t.skillsLoader != nil { + for _, si := range t.skillsLoader.ListSkills() { + results = append(results, toolSearchResult{ + Name: si.Name, + Description: si.Description, + Kind: "skill", + Tags: si.Tags, + Domain: si.Domain, + }) + } + } sort.Slice(results, func(i, j int) bool { return results[i].Name < results[j].Name diff --git a/pkg/tools/search_test.go b/pkg/tools/search_test.go index df8549a71..e776699b4 100644 --- a/pkg/tools/search_test.go +++ b/pkg/tools/search_test.go @@ -2,9 +2,11 @@ package tools import ( "context" + "os" "testing" jsonv2 "github.com/go-json-experiment/json" + "github.com/sipeed/picoclaw/pkg/skills" ) func TestToolSearchTool_Name(t *testing.T) { @@ -199,6 +201,106 @@ func TestToolSearchTool_ExcludesMetaTools(t *testing.T) { } } +func TestToolSearchTool_UnifiedSearch_IncludesSkills(t *testing.T) { + r := NewToolRegistry() + r.Register(&stubTool{name: "read_file", desc: "Read a file"}) + r.Register(&stubTool{name: "web_search", desc: "Search the web"}) + + tmpDir := t.TempDir() + sl := createTestSkillsDir(t, tmpDir, map[string]string{ + "git-commit": "---\nname: git-commit\ndescription: Git commit conventions\ntags: [git, workflow]\n---\n# Git Commit Skill", + "trading": "---\nname: trading\ndescription: Algorithmic trading strategies\ntags: [finance]\ndomain: quantitative\n---\n# Trading Skill", + }) + + s := NewToolSearchTool(r) + s.SetSkillsLoader(sl) + + // Search for "git" — should find the skill but not the tools + result := s.Execute(context.Background(), map[string]interface{}{"query": "git"}) + + var results []toolSearchResult + jsonv2.Unmarshal([]byte(result.ForLLM), &results) + + foundSkill := false + for _, res := range results { + if res.Name == "git-commit" && res.Kind == "skill" { + foundSkill = true + } + } + if !foundSkill { + t.Errorf("expected git-commit skill in results, got: %v", results) + } +} + +func TestToolSearchTool_UnifiedSearch_MixesToolsAndSkills(t *testing.T) { + r := NewToolRegistry() + r.Register(&stubTool{name: "web_search", desc: "Search the web for information"}) + + tmpDir := t.TempDir() + sl := createTestSkillsDir(t, tmpDir, map[string]string{ + "web-scraper": "---\nname: web-scraper\ndescription: Web scraping techniques\ntags: [web]\n---\n# Web Scraper", + }) + + s := NewToolSearchTool(r) + s.SetSkillsLoader(sl) + + result := s.Execute(context.Background(), map[string]interface{}{"query": "web"}) + + var results []toolSearchResult + jsonv2.Unmarshal([]byte(result.ForLLM), &results) + + hasToolKind := false + hasSkillKind := false + for _, res := range results { + if res.Kind == "tool" { + hasToolKind = true + } + if res.Kind == "skill" { + hasSkillKind = true + } + } + if !hasToolKind || !hasSkillKind { + t.Errorf("expected both tool and skill results, got: %v", results) + } +} + +func TestToolSearchTool_ListAll_IncludesSkills(t *testing.T) { + r := NewToolRegistry() + r.Register(&stubTool{name: "alpha", desc: "First tool"}) + + tmpDir := t.TempDir() + sl := createTestSkillsDir(t, tmpDir, map[string]string{ + "beta-skill": "---\nname: beta-skill\ndescription: A skill\n---\n# Skill", + }) + + s := NewToolSearchTool(r) + s.SetSkillsLoader(sl) + + result := s.Execute(context.Background(), map[string]interface{}{}) + + var results []toolSearchResult + jsonv2.Unmarshal([]byte(result.ForLLM), &results) + + if len(results) != 2 { + t.Errorf("expected 2 results (1 tool + 1 skill), got %d: %v", len(results), results) + } +} + +// createTestSkillsDir sets up a temp skills directory with SKILL.md files +func createTestSkillsDir(t *testing.T, baseDir string, skillContents map[string]string) *skills.SkillsLoader { + t.Helper() + for name, content := range skillContents { + skillDir := baseDir + "/" + name + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(skillDir+"/SKILL.md", []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + return skills.NewSkillsLoader(baseDir, "", "") +} + // --- fuzzyScore tests --- func TestFuzzyScore_ExactMatch(t *testing.T) { @@ -265,6 +367,36 @@ func TestSubsequenceMatch_False(t *testing.T) { } } +func TestToolToSchema_WithExamples(t *testing.T) { + tool := &stubToolWithExamples{ + stubTool: stubTool{name: "create_ticket", desc: "Create a support ticket"}, + examples: []map[string]interface{}{ + {"title": "Login page 500 error", "priority": "critical"}, + {"title": "Add dark mode"}, + }, + } + + schema := ToolToSchema(tool) + fn := schema["function"].(map[string]interface{}) + + examples, ok := fn["input_examples"].([]map[string]interface{}) + if !ok || len(examples) != 2 { + t.Fatalf("expected 2 input_examples, got: %v", fn["input_examples"]) + } + if examples[0]["priority"] != "critical" { + t.Errorf("expected critical priority in first example, got: %v", examples[0]) + } +} + +func TestToolToSchema_WithoutExamples(t *testing.T) { + tool := &stubTool{name: "read_file", desc: "Read a file"} + schema := ToolToSchema(tool) + fn := schema["function"].(map[string]interface{}) + if _, exists := fn["input_examples"]; exists { + t.Error("expected no input_examples for tool without ExampleProvider") + } +} + // --- stubTool for testing --- type stubTool struct { name string @@ -277,3 +409,12 @@ func (s *stubTool) Parameters() map[string]interface{} { return map[string]inter func (s *stubTool) Execute(_ context.Context, args map[string]interface{}) *ToolResult { return &ToolResult{ForLLM: "executed " + s.name} } + +type stubToolWithExamples struct { + stubTool + examples []map[string]interface{} +} + +func (s *stubToolWithExamples) InputExamples() []map[string]interface{} { + return s.examples +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 580690552..683ee52bc 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -440,10 +440,15 @@ func (t *ExecTool) SetTimeout(timeout time.Duration) { t.timeout = timeout } +// SetRestrictToWorkspace is the legacy name. Use SetRestrictToSandbox for new code. func (t *ExecTool) SetRestrictToWorkspace(restrict bool) { t.restrictToWorkspace = restrict } +func (t *ExecTool) SetRestrictToSandbox(restrict bool) { + t.restrictToWorkspace = restrict +} + func (t *ExecTool) SetMode(mode ShellMode) { t.mode = mode } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 0040d06d6..ddedd5f47 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -21,6 +21,10 @@ type SubagentTask struct { Created int64 } +// RunLoopFunc executes an agent tool loop. Injected from pkg/agent to break +// the import cycle between pkg/tools and pkg/fantasy. +type RunLoopFunc func(ctx context.Context, config ToolLoopConfig, systemPrompt, userPrompt, channel, chatID string) (*ToolLoopResult, error) + type SubagentManager struct { tasks map[string]*SubagentTask mu sync.RWMutex @@ -31,6 +35,7 @@ type SubagentManager struct { tools *ToolRegistry maxIterations int nextID int + runLoop RunLoopFunc } func NewSubagentManager(model fantasy.LanguageModel, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager { @@ -46,6 +51,23 @@ func NewSubagentManager(model fantasy.LanguageModel, defaultModel, workspace str } } +// SetRunLoop injects the loop runner function. Must be called before any +// subagent execution. When nil, falls back to the local RunToolLoop. +func (sm *SubagentManager) SetRunLoop(fn RunLoopFunc) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.runLoop = fn +} + +func (sm *SubagentManager) getRunLoop() RunLoopFunc { + sm.mu.RLock() + defer sm.mu.RUnlock() + if sm.runLoop != nil { + return sm.runLoop + } + return RunToolLoop +} + // SetTools sets the tool registry for subagent execution. func (sm *SubagentManager) SetTools(tools *ToolRegistry) { sm.mu.Lock() @@ -112,7 +134,8 @@ After completing the task, provide a clear summary of what was done.` maxIter := sm.maxIterations sm.mu.RUnlock() - loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ + runLoop := sm.getRunLoop() + loopResult, err := runLoop(ctx, ToolLoopConfig{ Model: sm.model, ModelID: sm.defaultModel, Tools: tools, @@ -251,7 +274,8 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{}) maxIter := sm.maxIterations sm.mu.RUnlock() - loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ + runLoop := sm.getRunLoop() + loopResult, err := runLoop(ctx, ToolLoopConfig{ Model: sm.model, ModelID: sm.defaultModel, Tools: tools, diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index fd8f7fd2d..fc3b8396d 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -151,6 +151,12 @@ func TestSubagentTool_Execute_Success(t *testing.T) { provider := &MockLanguageModel{} msgBus := bus.NewMessageBus() manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) { + return &ToolLoopResult{ + Content: "Task completed: " + userPrompt, + Iterations: 1, + }, nil + }) tool := NewSubagentTool(manager) tool.SetContext("telegram", "chat-123") @@ -207,6 +213,9 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) { provider := &MockLanguageModel{} msgBus := bus.NewMessageBus() manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) { + return &ToolLoopResult{Content: "Task completed: " + userPrompt, Iterations: 1}, nil + }) tool := NewSubagentTool(manager) ctx := context.Background() @@ -281,6 +290,9 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) { provider := &MockLanguageModel{} msgBus := bus.NewMessageBus() manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) { + return &ToolLoopResult{Content: "Task completed: " + userPrompt, Iterations: 1}, nil + }) tool := NewSubagentTool(manager) // Set context @@ -306,10 +318,12 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) { // TestSubagentTool_ForUserTruncation verifies long content is truncated for user func TestSubagentTool_ForUserTruncation(t *testing.T) { - // Create a mock provider that returns very long content provider := &MockLanguageModel{} msgBus := bus.NewMessageBus() manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) { + return &ToolLoopResult{Content: "Task completed: " + userPrompt, Iterations: 1}, nil + }) tool := NewSubagentTool(manager) ctx := context.Background() diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 86b528cbb..83104ff4f 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -1,5 +1,4 @@ // PicoClaw - Ultra-lightweight personal AI agent -// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors @@ -8,41 +7,11 @@ package tools import ( "context" - "fmt" - - jsonv2 "github.com/go-json-experiment/json" fantasy "charm.land/fantasy" "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/logger" ) -// ToolLoopMode controls whether the agent uses the sequential ReAct loop, -// the parallel DAG executor, or lets the router decide automatically. -type ToolLoopMode int - -const ( - ModeReAct ToolLoopMode = iota - ModeDAG - ModeAuto -) - -// DAGRunResult holds the output from a DAG execution. -type DAGRunResult struct { - Answer string - Tokens uint32 - Iterations int -} - -// DAGRunFunc executes a query through the DAG planner/executor pipeline. -// This function type breaks the import cycle between tools → dag → securebus → tools. -// The concrete implementation is wired in the application entry point. -type DAGRunFunc func(ctx context.Context, sessionKey, query string, availableTools []string) (*DAGRunResult, error) - -// RouteFunc classifies a query and returns the preferred execution mode. -// When nil, all queries use ModeReAct. -type RouteFunc func(mode ToolLoopMode, query string) ToolLoopMode - // ToolLoopConfig configures the tool execution loop. type ToolLoopConfig struct { Model fantasy.LanguageModel @@ -50,17 +19,6 @@ type ToolLoopConfig struct { Tools *ToolRegistry Bus *bus.MessageBus MaxIterations int - - // DAGRunner executes queries through the DAG planner/executor pipeline. - // When nil, all queries use the sequential ReAct loop. - DAGRunner DAGRunFunc - - // Router classifies queries into ModeReAct or ModeDAG. When nil, - // ModeReAct is always used. - Router RouteFunc - - // LoopMode controls execution routing. Default: ModeAuto. - LoopMode ToolLoopMode } // ToolLoopResult contains the result of running the tool loop. @@ -69,197 +27,9 @@ type ToolLoopResult struct { Iterations int } -// RunToolLoop executes the agent loop with PicoClaw tools. It supports two -// execution modes: -// - ReAct (sequential): Fantasy's step-by-step tool calling loop -// - DAG (parallel): LLMCompiler-style DAG planning and execution -// -// When LoopMode is ModeAuto, the router classifies the query to pick the -// optimal mode. The SecureBus enforces capabilities in both modes. -func RunToolLoop(ctx context.Context, config ToolLoopConfig, systemPrompt, userPrompt, channel, chatID string) (*ToolLoopResult, error) { - mode := config.LoopMode - if config.Router != nil { - mode = config.Router(mode, userPrompt) - } else if mode == ModeAuto { - mode = ModeReAct - } - - if mode == ModeDAG && config.DAGRunner != nil { - return runDAGLoop(ctx, config, userPrompt, channel) - } - - return runReActLoop(ctx, config, systemPrompt, userPrompt, channel, chatID) -} - -// runReActLoop is the original sequential Fantasy agent loop. -func runReActLoop(ctx context.Context, config ToolLoopConfig, systemPrompt, userPrompt, channel, chatID string) (*ToolLoopResult, error) { - adaptedTools := BuildAdaptedToolsFromRegistry(config.Tools, config.Bus, channel, chatID) - - agentOpts := []fantasy.AgentOption{ - fantasy.WithTools(adaptedTools...), - fantasy.WithStopConditions(fantasy.StepCountIs(config.MaxIterations)), - } - if systemPrompt != "" { - agentOpts = append(agentOpts, fantasy.WithSystemPrompt(systemPrompt)) - } - agent := fantasy.NewAgent(config.Model, agentOpts...) - - logger.DebugCF("toolloop", "ReAct mode: Fantasy agent created", - map[string]any{ - "tools_count": len(adaptedTools), - "max_iterations": config.MaxIterations, - }) - - result, err := agent.Generate(ctx, fantasy.AgentCall{ - Prompt: userPrompt, - }) - if err != nil { - logger.ErrorCF("toolloop", "Fantasy agent.Generate failed", - map[string]any{"error": err.Error()}) - return nil, fmt.Errorf("agent Generate failed: %w", err) - } - - finalContent := result.Response.Content.Text() - stepCount := len(result.Steps) - - logger.InfoCF("toolloop", "ReAct loop completed", - map[string]any{ - "steps": stepCount, - "content_chars": len(finalContent), - }) - - return &ToolLoopResult{ - Content: finalContent, - Iterations: stepCount, - }, nil -} - -// runDAGLoop uses the LLMCompiler-style DAG executor with replanning. -func runDAGLoop(ctx context.Context, config ToolLoopConfig, query, sessionKey string) (*ToolLoopResult, error) { - logger.InfoCF("toolloop", "DAG mode: planning and executing", - map[string]any{"query_len": len(query)}) - - availableTools := config.Tools.List() - - result, err := config.DAGRunner(ctx, sessionKey, query, availableTools) - if err != nil { - logger.ErrorCF("toolloop", "DAG execution failed", - map[string]any{"error": err.Error()}) - return nil, fmt.Errorf("DAG execution failed: %w", err) - } - - logger.InfoCF("toolloop", "DAG loop completed", - map[string]any{ - "iterations": result.Iterations, - "total_tokens": result.Tokens, - "answer_chars": len(result.Answer), - }) - - return &ToolLoopResult{ - Content: result.Answer, - Iterations: result.Iterations, - }, nil -} - -// BuildAdaptedToolsFromRegistry wraps all tools in a ToolRegistry as Fantasy AgentTools. -// This is a local wrapper that avoids circular imports by duplicating the adapter logic. -func BuildAdaptedToolsFromRegistry(registry *ToolRegistry, msgBus *bus.MessageBus, channel, chatID string) []fantasy.AgentTool { - if registry == nil { - return nil - } - - names := registry.List() - adapted := make([]fantasy.AgentTool, 0, len(names)) - - for _, name := range names { - tool, ok := registry.Get(name) - if !ok { - continue - } - adapted = append(adapted, &picoToolAdapter{ - inner: tool, - bus: msgBus, - channel: channel, - chatID: chatID, - }) - } - - return adapted -} - -// picoToolAdapter wraps a PicoClaw tool as a Fantasy AgentTool. -// This is a local copy to avoid circular imports with pkg/fantasy. -type picoToolAdapter struct { - inner Tool - bus *bus.MessageBus - channel string - chatID string -} - -func (a *picoToolAdapter) Info() fantasy.ToolInfo { - return fantasy.ToolInfo{ - Name: a.inner.Name(), - Description: a.inner.Description(), - Parameters: a.inner.Parameters(), - } -} - -func (a *picoToolAdapter) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - args, err := parseToolCallArgs(call.Input) - if err != nil { - return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil - } - - if ct, ok := a.inner.(ContextualTool); ok { - ct.SetContext(a.channel, a.chatID) - } - - if at, ok := a.inner.(AsyncTool); ok { - at.SetCallback(func(_ context.Context, result *ToolResult) { - if result != nil && result.ForUser != "" && !result.Silent && a.bus != nil { - a.bus.PublishOutbound(bus.OutboundMessage{ - Channel: a.channel, - ChatID: a.chatID, - Content: result.ForUser, - }) - } - }) - } - - result := a.inner.Execute(ctx, args) - if result == nil { - return fantasy.NewTextErrorResponse("tool returned nil result"), nil - } - - if result.ForUser != "" && !result.Silent && a.bus != nil { - a.bus.PublishOutbound(bus.OutboundMessage{ - Channel: a.channel, - ChatID: a.chatID, - Content: result.ForUser, - }) - } - - if result.IsError { - return fantasy.NewTextErrorResponse(result.ForLLM), nil - } - return fantasy.NewTextResponse(result.ForLLM), nil -} - -func (a *picoToolAdapter) ProviderOptions() fantasy.ProviderOptions { - return fantasy.ProviderOptions{} -} - -func (a *picoToolAdapter) SetProviderOptions(_ fantasy.ProviderOptions) {} - -// parseToolCallArgs deserializes JSON input string into args map. -func parseToolCallArgs(input string) (map[string]interface{}, error) { - if input == "" || input == "{}" { - return map[string]interface{}{}, nil - } - - var args map[string]interface{} - if err := jsonv2.Unmarshal([]byte(input), &args); err != nil { - return nil, fmt.Errorf("failed to parse tool arguments: %w", err) - } - return args, nil +// RunToolLoop is the package-level fallback used when no injected loop runner +// is available. It panics because the concrete implementation lives in +// pkg/agent and must be injected via SubagentManager.SetRunLoop. +func RunToolLoop(_ context.Context, _ ToolLoopConfig, _, _, _, _ string) (*ToolLoopResult, error) { + panic("tools.RunToolLoop called without injected loop runner; wire agent.MakeRunLoopFunc via SubagentManager.SetRunLoop") }