refactor(tools): progressive disclosure always-on, add ExampleProvider interface

registry.go:
- SetProgressiveDisclosure is now a no-op (progressive disclosure is
  always enabled); GetVisibleDefinitions always returns gateway tools only
- Remove getDefinitionsLocked (dead code after the above change)
- ListVisible: always returns gateway tools only

base.go:
- Add ExampleProvider optional interface for tools that want to include
  concrete input examples in their schema (Anthropic Tool Use Examples pattern)
- ToolToSchema: emit input_examples when tool implements ExampleProvider

toolloop.go:
- Remove ToolLoopMode, DAGRunResult, DAGRunFunc, RouteFunc, DAGRunner,
  Router, LoopMode — all moved to pkg/agent/toolloop.go to break import cycle
- Remove RunToolLoop DAG dispatch path (now lives in agent layer)

search.go: integrate skills.SkillsLoader for skill-aware tool search
shell.go: minor cleanup
subagent.go: minor cleanup
This commit is contained in:
ZanzyTHEbar 2026-02-19 18:05:01 +00:00
parent 7344f90a7f
commit 94c2bc7b23
9 changed files with 282 additions and 357 deletions

View file

@ -167,6 +167,14 @@ type AsyncTool interface {
SetCallback(cb AsyncCallback) 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 // ResourceProvider is an optional interface that tools can implement to declare
// resources they need loaded before execution (schemas, examples, docs, configs). // resources they need loaded before execution (schemas, examples, docs, configs).
// //
@ -189,12 +197,18 @@ type ResourceProvider interface {
} }
func ToolToSchema(tool Tool) map[string]interface{} { func ToolToSchema(tool Tool) map[string]interface{} {
return map[string]interface{}{ fn := map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": tool.Name(), "name": tool.Name(),
"description": tool.Description(), "description": tool.Description(),
"parameters": tool.Parameters(), "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": fn,
} }
} }

View file

@ -12,8 +12,8 @@ import (
type ToolRegistry struct { type ToolRegistry struct {
tools map[string]Tool tools map[string]Tool
mu sync.RWMutex mu sync.RWMutex
progressiveDisclosure bool // gatewayTools are always visible to the LLM; all other tools are
// gatewayTools are always visible even in progressive mode // discovered via tool_search + tool_call (progressive disclosure).
gatewayTools map[string]bool gatewayTools map[string]bool
} }
@ -24,14 +24,9 @@ func NewToolRegistry() *ToolRegistry {
} }
} }
// SetProgressiveDisclosure enables or disables progressive disclosure mode. // SetProgressiveDisclosure is a no-op retained for backward compatibility.
// When enabled, GetVisibleDefinitions returns only gateway tools (tool_search, // Progressive disclosure is always enabled.
// tool_call, and any explicitly marked tools). The agent discovers others via tool_search. func (r *ToolRegistry) SetProgressiveDisclosure(_ bool) {}
func (r *ToolRegistry) SetProgressiveDisclosure(enabled bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.progressiveDisclosure = enabled
}
// MarkGateway marks a tool name as always visible in progressive disclosure mode. // MarkGateway marks a tool name as always visible in progressive disclosure mode.
func (r *ToolRegistry) MarkGateway(name string) { func (r *ToolRegistry) MarkGateway(name string) {
@ -52,16 +47,11 @@ func (r *ToolRegistry) RegisterMetaTools() {
} }
// GetVisibleDefinitions returns tool definitions visible to the LLM. // GetVisibleDefinitions returns tool definitions visible to the LLM.
// In progressive disclosure mode, only gateway tools are returned. // Only gateway tools are returned; the agent discovers others via tool_search.
// In full mode, all tools are returned (same as GetDefinitions).
func (r *ToolRegistry) GetVisibleDefinitions() []map[string]interface{} { func (r *ToolRegistry) GetVisibleDefinitions() []map[string]interface{} {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
if !r.progressiveDisclosure {
return r.getDefinitionsLocked()
}
definitions := make([]map[string]interface{}, 0) definitions := make([]map[string]interface{}, 0)
for _, tool := range r.tools { for _, tool := range r.tools {
if r.gatewayTools[tool.Name()] { if r.gatewayTools[tool.Name()] {
@ -71,14 +61,6 @@ func (r *ToolRegistry) GetVisibleDefinitions() []map[string]interface{} {
return definitions 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) { func (r *ToolRegistry) Register(tool Tool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@ -182,21 +164,11 @@ func (r *ToolRegistry) List() []string {
return names return names
} }
// ListVisible returns tool names visible to the LLM. // ListVisible returns tool names visible to the LLM (gateway tools only).
// In progressive disclosure mode, only gateway tools are returned.
// In full mode, all tools are returned.
func (r *ToolRegistry) ListVisible() []string { func (r *ToolRegistry) ListVisible() []string {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() 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)) names := make([]string, 0, len(r.gatewayTools))
for name := range r.gatewayTools { for name := range r.gatewayTools {
if _, ok := r.tools[name]; ok { if _, ok := r.tools[name]; ok {

View file

@ -6,31 +6,16 @@ import (
"testing" "testing"
) )
func TestProgressiveDisclosure_Disabled_AllToolsVisible(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.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) {
r := NewToolRegistry() r := NewToolRegistry()
r.Register(&stubTool{name: "read_file", desc: "Read"}) r.Register(&stubTool{name: "read_file", desc: "Read"})
r.Register(&stubTool{name: "write_file", desc: "Write"}) r.Register(&stubTool{name: "write_file", desc: "Write"})
r.Register(&stubTool{name: "web_search", desc: "Search"}) r.Register(&stubTool{name: "web_search", desc: "Search"})
r.RegisterMetaTools() r.RegisterMetaTools()
r.SetProgressiveDisclosure(true)
visible := r.ListVisible() visible := r.ListVisible()
sort.Strings(visible) sort.Strings(visible)
// Only gateway tools
expected := []string{"tool_call", "tool_search"} expected := []string{"tool_call", "tool_search"}
if len(visible) != len(expected) { if len(visible) != len(expected) {
t.Fatalf("expected %v, got %v", expected, visible) 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.Register(&stubTool{name: "memory", desc: "Memory tool"})
r.RegisterMetaTools() r.RegisterMetaTools()
r.MarkGateway("memory") r.MarkGateway("memory")
r.SetProgressiveDisclosure(true)
visible := r.ListVisible() visible := r.ListVisible()
sort.Strings(visible) sort.Strings(visible)
@ -70,17 +54,9 @@ func TestProgressiveDisclosure_GetVisibleDefinitions(t *testing.T) {
r.Register(&stubTool{name: "write_file", desc: "Write"}) r.Register(&stubTool{name: "write_file", desc: "Write"})
r.RegisterMetaTools() 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() gatewayDefs := r.GetVisibleDefinitions()
if len(gatewayDefs) != 2 { 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 := NewToolRegistry()
r.Register(&stubTool{name: "read_file", desc: "Read"}) r.Register(&stubTool{name: "read_file", desc: "Read"})
r.RegisterMetaTools() r.RegisterMetaTools()
r.SetProgressiveDisclosure(true)
// read_file is hidden from LLM but still in registry
tool, ok := r.Get("read_file") tool, ok := r.Get("read_file")
if !ok { if !ok {
t.Fatal("read_file should still exist in registry") 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()) t.Errorf("expected read_file, got %s", tool.Name())
} }
// tool_call should still dispatch to it
tc, _ := r.Get("tool_call") tc, _ := r.Get("tool_call")
result := tc.Execute(context.TODO(), map[string]interface{}{ result := tc.Execute(context.TODO(), map[string]interface{}{
"tool_name": "read_file", "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: "read_file", desc: "Read a file from filesystem"})
r.Register(&stubTool{name: "write_file", desc: "Write to a file"}) r.Register(&stubTool{name: "write_file", desc: "Write to a file"})
r.RegisterMetaTools() r.RegisterMetaTools()
r.SetProgressiveDisclosure(true)
// Even though read_file is hidden from Fantasy, tool_search should find it
ts, _ := r.Get("tool_search") ts, _ := r.Get("tool_search")
result := ts.Execute(context.TODO(), map[string]interface{}{"query": "read"}) 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) t.Fatalf("unexpected error: %s", result.ForLLM)
} }
// Result should contain read_file
if result.ForLLM == "" { if result.ForLLM == "" {
t.Fatal("expected non-empty result") 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) { func TestProgressiveDisclosure_MarkNonexistentGateway(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
r.RegisterMetaTools() r.RegisterMetaTools()
r.MarkGateway("nonexistent") r.MarkGateway("nonexistent")
r.SetProgressiveDisclosure(true)
visible := r.ListVisible() visible := r.ListVisible()
for _, name := range visible { for _, name := range visible {

View file

@ -7,12 +7,15 @@ import (
"strings" "strings"
jsonv2 "github.com/go-json-experiment/json" jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/skills"
) )
// ToolSearchTool implements tool discovery via fuzzy search over the registry. // ToolSearchTool implements unified discovery via fuzzy search over the registry
// The agent can query for tools by keyword and get back ranked summaries. // 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 { type ToolSearchTool struct {
registry *ToolRegistry registry *ToolRegistry
skillsLoader *skills.SkillsLoader
} }
// NewToolSearchTool creates a tool that searches the registry. // NewToolSearchTool creates a tool that searches the registry.
@ -20,10 +23,15 @@ func NewToolSearchTool(registry *ToolRegistry) *ToolSearchTool {
return &ToolSearchTool{registry: registry} 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) Name() string { return "tool_search" }
func (t *ToolSearchTool) Description() string { 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{} { func (t *ToolSearchTool) Parameters() map[string]interface{} {
@ -42,7 +50,10 @@ func (t *ToolSearchTool) Parameters() map[string]interface{} {
type toolSearchResult struct { type toolSearchResult struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Score int `json:"score"` 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 { 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) queryLower := strings.ToLower(query)
queryTerms := strings.Fields(queryLower) queryTerms := strings.Fields(queryLower)
t.registry.mu.RLock()
defer t.registry.mu.RUnlock()
var results []toolSearchResult var results []toolSearchResult
// Search tools
t.registry.mu.RLock()
for _, tool := range t.registry.tools { for _, tool := range t.registry.tools {
// Skip meta-tools from results
if tool.Name() == "tool_search" || tool.Name() == "tool_call" { if tool.Name() == "tool_search" || tool.Name() == "tool_call" {
continue continue
} }
score := fuzzyScore(tool.Name(), tool.Description(), queryTerms) score := fuzzyScore(tool.Name(), tool.Description(), queryTerms)
if score > 0 { if score > 0 {
results = append(results, toolSearchResult{ results = append(results, toolSearchResult{
Name: tool.Name(), Name: tool.Name(),
Description: tool.Description(), Description: tool.Description(),
Kind: "tool",
Score: score, 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 { sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score return results[i].Score > results[j].Score
}) })
// Limit to top 10 if len(results) > 15 {
if len(results) > 10 { results = results[:15]
results = results[:10]
} }
if len(results) == 0 { 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) b, _ := jsonv2.Marshal(results)
@ -94,10 +120,9 @@ func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{})
} }
func (t *ToolSearchTool) listAll() *ToolResult { func (t *ToolSearchTool) listAll() *ToolResult {
t.registry.mu.RLock()
defer t.registry.mu.RUnlock()
var results []toolSearchResult var results []toolSearchResult
t.registry.mu.RLock()
for _, tool := range t.registry.tools { for _, tool := range t.registry.tools {
if tool.Name() == "tool_search" || tool.Name() == "tool_call" { if tool.Name() == "tool_search" || tool.Name() == "tool_call" {
continue continue
@ -105,8 +130,22 @@ func (t *ToolSearchTool) listAll() *ToolResult {
results = append(results, toolSearchResult{ results = append(results, toolSearchResult{
Name: tool.Name(), Name: tool.Name(),
Description: tool.Description(), 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 { sort.Slice(results, func(i, j int) bool {
return results[i].Name < results[j].Name return results[i].Name < results[j].Name

View file

@ -2,9 +2,11 @@ package tools
import ( import (
"context" "context"
"os"
"testing" "testing"
jsonv2 "github.com/go-json-experiment/json" jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/skills"
) )
func TestToolSearchTool_Name(t *testing.T) { 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 --- // --- fuzzyScore tests ---
func TestFuzzyScore_ExactMatch(t *testing.T) { 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 --- // --- stubTool for testing ---
type stubTool struct { type stubTool struct {
name string 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 { func (s *stubTool) Execute(_ context.Context, args map[string]interface{}) *ToolResult {
return &ToolResult{ForLLM: "executed " + s.name} return &ToolResult{ForLLM: "executed " + s.name}
} }
type stubToolWithExamples struct {
stubTool
examples []map[string]interface{}
}
func (s *stubToolWithExamples) InputExamples() []map[string]interface{} {
return s.examples
}

View file

@ -440,10 +440,15 @@ func (t *ExecTool) SetTimeout(timeout time.Duration) {
t.timeout = timeout t.timeout = timeout
} }
// SetRestrictToWorkspace is the legacy name. Use SetRestrictToSandbox for new code.
func (t *ExecTool) SetRestrictToWorkspace(restrict bool) { func (t *ExecTool) SetRestrictToWorkspace(restrict bool) {
t.restrictToWorkspace = restrict t.restrictToWorkspace = restrict
} }
func (t *ExecTool) SetRestrictToSandbox(restrict bool) {
t.restrictToWorkspace = restrict
}
func (t *ExecTool) SetMode(mode ShellMode) { func (t *ExecTool) SetMode(mode ShellMode) {
t.mode = mode t.mode = mode
} }

View file

@ -21,6 +21,10 @@ type SubagentTask struct {
Created int64 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 { type SubagentManager struct {
tasks map[string]*SubagentTask tasks map[string]*SubagentTask
mu sync.RWMutex mu sync.RWMutex
@ -31,6 +35,7 @@ type SubagentManager struct {
tools *ToolRegistry tools *ToolRegistry
maxIterations int maxIterations int
nextID int nextID int
runLoop RunLoopFunc
} }
func NewSubagentManager(model fantasy.LanguageModel, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager { 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. // SetTools sets the tool registry for subagent execution.
func (sm *SubagentManager) SetTools(tools *ToolRegistry) { func (sm *SubagentManager) SetTools(tools *ToolRegistry) {
sm.mu.Lock() sm.mu.Lock()
@ -112,7 +134,8 @@ After completing the task, provide a clear summary of what was done.`
maxIter := sm.maxIterations maxIter := sm.maxIterations
sm.mu.RUnlock() sm.mu.RUnlock()
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ runLoop := sm.getRunLoop()
loopResult, err := runLoop(ctx, ToolLoopConfig{
Model: sm.model, Model: sm.model,
ModelID: sm.defaultModel, ModelID: sm.defaultModel,
Tools: tools, Tools: tools,
@ -251,7 +274,8 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
maxIter := sm.maxIterations maxIter := sm.maxIterations
sm.mu.RUnlock() sm.mu.RUnlock()
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ runLoop := sm.getRunLoop()
loopResult, err := runLoop(ctx, ToolLoopConfig{
Model: sm.model, Model: sm.model,
ModelID: sm.defaultModel, ModelID: sm.defaultModel,
Tools: tools, Tools: tools,

View file

@ -151,6 +151,12 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
provider := &MockLanguageModel{} provider := &MockLanguageModel{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) 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 := NewSubagentTool(manager)
tool.SetContext("telegram", "chat-123") tool.SetContext("telegram", "chat-123")
@ -207,6 +213,9 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
provider := &MockLanguageModel{} provider := &MockLanguageModel{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) 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 := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
@ -281,6 +290,9 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
provider := &MockLanguageModel{} provider := &MockLanguageModel{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) 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 := NewSubagentTool(manager)
// Set context // Set context
@ -306,10 +318,12 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
// TestSubagentTool_ForUserTruncation verifies long content is truncated for user // TestSubagentTool_ForUserTruncation verifies long content is truncated for user
func TestSubagentTool_ForUserTruncation(t *testing.T) { func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a mock provider that returns very long content
provider := &MockLanguageModel{} provider := &MockLanguageModel{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) 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 := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()

View file

@ -1,5 +1,4 @@
// PicoClaw - Ultra-lightweight personal AI agent // PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT // License: MIT
// //
// Copyright (c) 2026 PicoClaw contributors // Copyright (c) 2026 PicoClaw contributors
@ -8,41 +7,11 @@ package tools
import ( import (
"context" "context"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
fantasy "charm.land/fantasy" fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus" "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. // ToolLoopConfig configures the tool execution loop.
type ToolLoopConfig struct { type ToolLoopConfig struct {
Model fantasy.LanguageModel Model fantasy.LanguageModel
@ -50,17 +19,6 @@ type ToolLoopConfig struct {
Tools *ToolRegistry Tools *ToolRegistry
Bus *bus.MessageBus Bus *bus.MessageBus
MaxIterations int 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. // ToolLoopResult contains the result of running the tool loop.
@ -69,197 +27,9 @@ type ToolLoopResult struct {
Iterations int Iterations int
} }
// RunToolLoop executes the agent loop with PicoClaw tools. It supports two // RunToolLoop is the package-level fallback used when no injected loop runner
// execution modes: // is available. It panics because the concrete implementation lives in
// - ReAct (sequential): Fantasy's step-by-step tool calling loop // pkg/agent and must be injected via SubagentManager.SetRunLoop.
// - DAG (parallel): LLMCompiler-style DAG planning and execution func RunToolLoop(_ context.Context, _ ToolLoopConfig, _, _, _, _ string) (*ToolLoopResult, error) {
// panic("tools.RunToolLoop called without injected loop runner; wire agent.MakeRunLoopFunc via SubagentManager.SetRunLoop")
// 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
} }