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:
parent
7344f90a7f
commit
94c2bc7b23
9 changed files with 282 additions and 357 deletions
|
|
@ -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{} {
|
||||
return map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": 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": fn,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
type ToolRegistry struct {
|
||||
tools map[string]Tool
|
||||
mu sync.RWMutex
|
||||
progressiveDisclosure bool
|
||||
// gatewayTools are always visible even in progressive mode
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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{} {
|
||||
|
|
@ -42,7 +50,10 @@ func (t *ToolSearchTool) Parameters() map[string]interface{} {
|
|||
type toolSearchResult struct {
|
||||
Name string `json:"name"`
|
||||
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 {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue