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)
|
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{} {
|
||||||
|
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{}{
|
return map[string]interface{}{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": map[string]interface{}{
|
"function": fn,
|
||||||
"name": tool.Name(),
|
|
||||||
"description": tool.Description(),
|
|
||||||
"parameters": tool.Parameters(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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{} {
|
||||||
|
|
@ -40,9 +48,12 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue