feat(tools): dynamic tool promotion via PrepareStep progressive disclosure

Implements the full progressive disclosure loop: tool_search returns
parameter schemas, records discovered tools, and the agent's PrepareStep
callback promotes them to native callables before the next inference step.
The LLM can call discovered tools directly without going through tool_call.

pkg/tools/registry.go
- MarkDiscovered(names...) records tools returned by tool_search (thread-safe)
- DrainDiscovered() atomically returns and clears the discovered set;
  called by PrepareStep each step to get newly promoted tools
- IsGateway(name) and GetSchema(name) added as convenience accessors

pkg/tools/search.go
- toolSearchResult now includes Parameters (properties map) and Required
  fields so the LLM has the full schema inline in search results
- Execute and listAll both call registry.MarkDiscovered() after building
  results so PrepareStep can promote them
- extractSchemaFields() helper pulls properties + required from a JSON
  Schema parameters object
- Description updated: discovered tools are directly callable; tool_call
  is a fallback only

pkg/tools/search_test.go
- TestToolSearchTool_DiscoveryTracking: MarkDiscovered/DrainDiscovered round-trip
- TestToolSearchTool_DiscoverySkipsGateway: gateway tools excluded from set
- TestToolSearchTool_DiscoverySkipsMetaTools: tool_search/tool_call excluded
- TestToolSearchTool_ReturnsSchema: search results include parameters + required
- TestToolSearchTool_ListAll_ReturnsSchema: listAll also includes schemas

pkg/fantasy/adapter.go
- AdaptTools() wraps a slice of tools.Tool as fantasy.AgentTool without
  going through the full registry; used by PrepareStep to promote tools

pkg/agent/loop.go
- assembleContext builds a PrepareStep closure that calls DrainDiscovered
  each step, adapts new tools via AdaptTools, appends them to adaptedTools,
  and passes the expanded slice to fantasy.WithPrepareStep
- Gateway set expanded: read_file, write_file, list_dir, exec always visible
  (in addition to memory); skill_search removed from gateway set
- Log line emitted when tools are dynamically promoted
This commit is contained in:
ZanzyTHEbar 2026-02-21 00:40:08 +00:00
parent 938b77f9b5
commit 3d940883a6
5 changed files with 474 additions and 53 deletions

View file

@ -71,7 +71,6 @@ type processOptions struct {
ChatID string // Target chat ID for tool execution ChatID string // Target chat ID for tool execution
SenderID string // Originating sender identifier (for logging/audit) SenderID string // Originating sender identifier (for logging/audit)
UserMessage string // User message content (may include prefix) UserMessage string // User message content (may include prefix)
DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus SendResponse bool // Whether to send response via bus
NoHistory bool // If true, don't load session history (for heartbeat) NoHistory bool // If true, don't load session history (for heartbeat)
@ -242,10 +241,22 @@ func NewAgentLoop(ctx context.Context, cfg *config.Config, msgBus *bus.MessageBu
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir, session.WithSessionDelegate(memDelegate, "picoclaw")) sessionsManager := session.NewSessionManager(sessionsDir, session.WithSessionDelegate(memDelegate, "picoclaw"))
// Meta-tools for progressive disclosure (tool_search + tool_call) // Meta-tools for progressive disclosure (tool_search + tool_call).
// tool_search returns full parameter schemas; discovered tools are
// dynamically promoted to native callables via PrepareStep.
toolsRegistry.RegisterMetaTools() toolsRegistry.RegisterMetaTools()
toolsRegistry.MarkGateway("memory")
toolsRegistry.MarkGateway("skill_search") // Gateway tools: always visible to the LLM. Keep this set minimal —
// dynamic promotion handles everything else after tool_search.
for _, name := range []string{
"memory",
"read_file",
"write_file",
"list_dir",
"exec",
} {
toolsRegistry.MarkGateway(name)
}
// Wire skills loader into tool_search for unified discovery // Wire skills loader into tool_search for unified discovery
if ts, ok := toolsRegistry.Get("tool_search"); ok { if ts, ok := toolsRegistry.Get("tool_search"); ok {
@ -442,7 +453,6 @@ func (al *AgentLoop) ProcessDirectStreaming(ctx context.Context, content, sessio
ChatID: msg.ChatID, ChatID: msg.ChatID,
SenderID: msg.SenderID, SenderID: msg.SenderID,
UserMessage: msg.Content, UserMessage: msg.Content,
DefaultResponse: "I've completed processing but have no response to give.",
EnableSummary: true, EnableSummary: true,
SendResponse: false, SendResponse: false,
Streaming: true, Streaming: true,
@ -457,7 +467,6 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha
Channel: channel, Channel: channel,
ChatID: chatID, ChatID: chatID,
UserMessage: content, UserMessage: content,
DefaultResponse: "I've completed processing but have no response to give.",
EnableSummary: false, EnableSummary: false,
SendResponse: false, SendResponse: false,
NoHistory: true, // Don't load session history for heartbeat NoHistory: true, // Don't load session history for heartbeat
@ -496,7 +505,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Channel: msg.Channel, Channel: msg.Channel,
ChatID: msg.ChatID, ChatID: msg.ChatID,
UserMessage: msg.Content, UserMessage: msg.Content,
DefaultResponse: "I've completed processing but have no response to give.",
EnableSummary: true, EnableSummary: true,
SendResponse: false, SendResponse: false,
}) })
@ -631,9 +639,56 @@ func (al *AgentLoop) assembleContext(ctx context.Context, opts processOptions) a
} }
adaptedTools := picofantasy.BuildAdaptedTools(al.tools, al.bus, opts.Channel, opts.ChatID, adaptCfg) adaptedTools := picofantasy.BuildAdaptedTools(al.tools, al.bus, opts.Channel, opts.ChatID, adaptCfg)
// Dynamic tool promotion via PrepareStep: after tool_search discovers tools,
// they become native callables in the next inference step — no tool_call needed.
promotedSet := make(map[string]bool)
for _, at := range adaptedTools {
promotedSet[at.Info().Name] = true
}
registry := al.tools
msgBus := al.bus
channel := opts.Channel
chatID := opts.ChatID
prepareStep := func(ctx context.Context, psOpts fantasy.PrepareStepFunctionOptions) (context.Context, fantasy.PrepareStepResult, error) {
discovered := registry.DrainDiscovered()
if len(discovered) == 0 {
return ctx, fantasy.PrepareStepResult{}, nil
}
var newTools []tools.Tool
for _, t := range discovered {
if promotedSet[t.Name()] {
continue
}
newTools = append(newTools, t)
promotedSet[t.Name()] = true
}
if len(newTools) == 0 {
return ctx, fantasy.PrepareStepResult{}, nil
}
newAdapted := picofantasy.AdaptTools(newTools, msgBus, channel, chatID, adaptCfg)
expanded := append(adaptedTools, newAdapted...)
adaptedTools = expanded
logger.InfoCF("agent", "Dynamic tool promotion via PrepareStep",
map[string]interface{}{
"promoted": len(newTools),
"total_tools": len(expanded),
"names": toolNames(newTools),
})
return ctx, fantasy.PrepareStepResult{
Tools: expanded,
}, nil
}
agentOpts := []fantasy.AgentOption{ agentOpts := []fantasy.AgentOption{
fantasy.WithTools(adaptedTools...), fantasy.WithTools(adaptedTools...),
fantasy.WithStopConditions(fantasy.StepCountIs(al.maxIterations)), fantasy.WithStopConditions(fantasy.StepCountIs(al.maxIterations)),
fantasy.WithPrepareStep(prepareStep),
} }
if systemPrompt != "" { if systemPrompt != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(systemPrompt)) agentOpts = append(agentOpts, fantasy.WithSystemPrompt(systemPrompt))
@ -668,10 +723,6 @@ func (al *AgentLoop) assembleContext(ctx context.Context, opts processOptions) a
// postProcess handles the common finalization after Generate or Stream: // postProcess handles the common finalization after Generate or Stream:
// extract final text, save session, summarize, observe, optionally send response. // extract final text, save session, summarize, observe, optionally send response.
func (al *AgentLoop) postProcess(ctx context.Context, opts processOptions, finalContent string, stepCount int) string { func (al *AgentLoop) postProcess(ctx context.Context, opts processOptions, finalContent string, stepCount int) string {
if finalContent == "" {
finalContent = opts.DefaultResponse
}
al.sessions.Save(opts.SessionKey) al.sessions.Save(opts.SessionKey)
if opts.EnableSummary { if opts.EnableSummary {
@ -700,6 +751,98 @@ func (al *AgentLoop) postProcess(ctx context.Context, opts processOptions, final
return finalContent return finalContent
} }
// resolveFinalContent normalizes the final assistant response from an agent run.
// Some providers return an empty final response even though an earlier step
// already produced text. In that case, recover the latest non-empty text from
// steps. If no text exists at all, return a deterministic error.
func (al *AgentLoop) resolveFinalContent(finalContent string, steps []fantasy.StepResult) (string, error) {
trimmed := strings.TrimSpace(finalContent)
if trimmed != "" {
return trimmed, nil
}
for i := len(steps) - 1; i >= 0; i-- {
stepText := strings.TrimSpace(steps[i].Content.Text())
if stepText != "" {
logger.WarnCF("agent", "Recovered empty final response from prior step text",
map[string]interface{}{
"step_index": i,
})
return stepText, nil
}
}
type candidate struct {
text string
score int
}
candidates := make([]candidate, 0, 8)
for i := len(steps) - 1; i >= 0; i-- {
toolResults := steps[i].Content.ToolResults()
for j := len(toolResults) - 1; j >= 0; j-- {
tr := toolResults[j]
switch out := tr.Result.(type) {
case fantasy.ToolResultOutputContentText:
txt := strings.TrimSpace(out.Text)
if txt != "" {
score := 2
if tr.ToolName == "tool_search" || strings.Contains(strings.ToLower(txt), "\"kind\":\"tool\"") {
score = 0
}
if strings.Contains(strings.ToLower(txt), "tool not found") ||
strings.Contains(strings.ToLower(txt), "path is required") {
score = -1
}
candidates = append(candidates, candidate{text: txt, score: score})
}
case fantasy.ToolResultOutputContentError:
if out.Error != nil {
txt := strings.TrimSpace(out.Error.Error())
if txt != "" {
candidates = append(candidates, candidate{text: txt, score: -1})
}
}
case fantasy.ToolResultOutputContentMedia:
txt := strings.TrimSpace(out.Text)
if txt != "" {
candidates = append(candidates, candidate{text: txt, score: 1})
}
}
if len(candidates) >= 8 {
break
}
}
if len(candidates) >= 8 {
break
}
}
bestText := ""
bestScore := -1000
for _, c := range candidates {
if c.score > bestScore {
bestScore = c.score
bestText = c.text
}
}
if bestText != "" && bestScore > 0 {
logger.WarnCF("agent", "Recovered empty final response from tool results",
map[string]interface{}{
"candidates": len(candidates),
"score": bestScore,
})
return bestText, nil
}
toolCalls := 0
for _, step := range steps {
toolCalls += len(step.Content.ToolCalls())
}
return "", fmt.Errorf("agent produced no final response text (steps=%d, tool_calls=%d)", len(steps), toolCalls)
}
// runAgentLoop is the core message processing logic. // runAgentLoop is the core message processing logic.
// It delegates to assembleContext for shared pre-processing, then branches on // It delegates to assembleContext for shared pre-processing, then branches on
// opts.Streaming to either Generate (synchronous) or Stream (real-time deltas). // opts.Streaming to either Generate (synchronous) or Stream (real-time deltas).
@ -730,7 +873,15 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
al.auditStep(ctx, step, opts.SessionKey) al.auditStep(ctx, step, opts.SessionKey)
} }
finalContent := result.Response.Content.Text() finalContent, err := al.resolveFinalContent(result.Response.Content.Text(), result.Steps)
if err != nil {
logger.ErrorCF("agent", "Agent finished without final response text",
map[string]interface{}{
"error": err.Error(),
"steps": len(result.Steps),
})
return "", err
}
return al.postProcess(ctx, opts, finalContent, len(result.Steps)), nil return al.postProcess(ctx, opts, finalContent, len(result.Steps)), nil
} }
@ -779,7 +930,15 @@ func (al *AgentLoop) runStreaming(ctx context.Context, opts processOptions, ac a
return "", fmt.Errorf("agent Stream failed: %w", err) return "", fmt.Errorf("agent Stream failed: %w", err)
} }
finalContent := result.Response.Content.Text() finalContent, err := al.resolveFinalContent(result.Response.Content.Text(), result.Steps)
if err != nil {
logger.ErrorCF("agent", "Streaming agent finished without final response text",
map[string]interface{}{
"error": err.Error(),
"steps": len(result.Steps),
})
return "", err
}
return al.postProcess(ctx, opts, finalContent, len(result.Steps)), nil return al.postProcess(ctx, opts, finalContent, len(result.Steps)), nil
} }
@ -1302,3 +1461,11 @@ func (al *AgentLoop) handleCommand(_ context.Context, msg bus.InboundMessage) (s
return "", false return "", false
} }
func toolNames(tt []tools.Tool) []string {
names := make([]string, len(tt))
for i, t := range tt {
names[i] = t.Name()
}
return names
}

View file

@ -196,6 +196,24 @@ func BuildAdaptedTools(registry *tools.ToolRegistry, msgBus *bus.MessageBus, cha
return adapted return adapted
} }
// AdaptTools wraps specific Tool instances as Fantasy AgentTools.
// Used by PrepareStep to promote discovered tools to native callables.
func AdaptTools(picoTools []tools.Tool, msgBus *bus.MessageBus, channel, chatID string, cfg AdaptedToolsConfig) []fantasy.AgentTool {
adapted := make([]fantasy.AgentTool, 0, len(picoTools))
for _, tool := range picoTools {
adapted = append(adapted, &PicoToolAdapter{
inner: tool,
bus: msgBus,
channel: channel,
chatID: chatID,
memStore: cfg.MemStore,
agentID: cfg.AgentID,
sessionKey: cfg.SessionKey,
})
}
return adapted
}
// parseToolArgs deserializes a JSON string into a map. // parseToolArgs deserializes a JSON string into a map.
// Handles both JSON objects and empty inputs gracefully. // Handles both JSON objects and empty inputs gracefully.
func parseToolArgs(input string) (map[string]interface{}, error) { func parseToolArgs(input string) (map[string]interface{}, error) {

View file

@ -15,12 +15,18 @@ type ToolRegistry struct {
// gatewayTools are always visible to the LLM; all other tools are // gatewayTools are always visible to the LLM; all other tools are
// discovered via tool_search + tool_call (progressive disclosure). // discovered via tool_search + tool_call (progressive disclosure).
gatewayTools map[string]bool gatewayTools map[string]bool
// discoveredTools tracks tools that tool_search has returned as results
// in the current session. PrepareStep drains this set to dynamically
// promote discovered tools to native callables.
discoveredTools map[string]bool
discoveredMu sync.Mutex
} }
func NewToolRegistry() *ToolRegistry { func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{ return &ToolRegistry{
tools: make(map[string]Tool), tools: make(map[string]Tool),
gatewayTools: make(map[string]bool), gatewayTools: make(map[string]bool),
discoveredTools: make(map[string]bool),
} }
} }
@ -197,3 +203,60 @@ func (r *ToolRegistry) GetSummaries() []string {
} }
return summaries return summaries
} }
// MarkDiscovered records that a tool was returned by tool_search.
// Thread-safe; called from tool_search's Execute path.
func (r *ToolRegistry) MarkDiscovered(names ...string) {
r.discoveredMu.Lock()
defer r.discoveredMu.Unlock()
for _, name := range names {
if name == "tool_search" || name == "tool_call" {
continue
}
if r.gatewayTools[name] {
continue
}
r.discoveredTools[name] = true
}
}
// DrainDiscovered atomically returns and clears the set of tools discovered
// since the last drain. PrepareStep calls this to promote discovered tools
// to native callables for the next inference step.
func (r *ToolRegistry) DrainDiscovered() []Tool {
r.discoveredMu.Lock()
names := make([]string, 0, len(r.discoveredTools))
for name := range r.discoveredTools {
names = append(names, name)
}
r.discoveredTools = make(map[string]bool)
r.discoveredMu.Unlock()
r.mu.RLock()
defer r.mu.RUnlock()
promoted := make([]Tool, 0, len(names))
for _, name := range names {
if tool, ok := r.tools[name]; ok {
promoted = append(promoted, tool)
}
}
return promoted
}
// IsGateway returns true if the named tool is a gateway tool.
func (r *ToolRegistry) IsGateway(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.gatewayTools[name]
}
// GetSchema returns the full parameter schema for a named tool, or nil if not found.
func (r *ToolRegistry) GetSchema(name string) map[string]interface{} {
r.mu.RLock()
defer r.mu.RUnlock()
tool, ok := r.tools[name]
if !ok {
return nil
}
return tool.Parameters()
}

View file

@ -31,7 +31,7 @@ func (t *ToolSearchTool) SetSkillsLoader(sl *skills.SkillsLoader) {
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 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)." return "Search for available tools and skills by keyword. Returns names, descriptions, parameter schemas, and kind (tool or skill). Discovered tools become directly callable in your next step — no need to use tool_call. For skills, use skill_read to load full content."
} }
func (t *ToolSearchTool) Parameters() map[string]interface{} { func (t *ToolSearchTool) Parameters() map[string]interface{} {
@ -54,6 +54,8 @@ type toolSearchResult struct {
Score int `json:"score,omitempty"` Score int `json:"score,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Domain string `json:"domain,omitempty"` Domain string `json:"domain,omitempty"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
Required []string `json:"required,omitempty"`
} }
func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{}) *ToolResult { func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{}) *ToolResult {
@ -67,7 +69,7 @@ func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{})
var results []toolSearchResult var results []toolSearchResult
// Search tools // Search tools — include parameter schemas so the LLM can call them correctly
t.registry.mu.RLock() 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" {
@ -75,11 +77,14 @@ func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{})
} }
score := fuzzyScore(tool.Name(), tool.Description(), queryTerms) score := fuzzyScore(tool.Name(), tool.Description(), queryTerms)
if score > 0 { if score > 0 {
params, required := extractSchemaFields(tool.Parameters())
results = append(results, toolSearchResult{ results = append(results, toolSearchResult{
Name: tool.Name(), Name: tool.Name(),
Description: tool.Description(), Description: tool.Description(),
Kind: "tool", Kind: "tool",
Score: score, Score: score,
Parameters: params,
Required: required,
}) })
} }
} }
@ -115,10 +120,42 @@ func (t *ToolSearchTool) Execute(_ context.Context, args map[string]interface{})
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)} 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)}
} }
// Record discovered tool names so PrepareStep can promote them to native callables.
discoveredNames := make([]string, 0, len(results))
for _, r := range results {
if r.Kind == "tool" {
discoveredNames = append(discoveredNames, r.Name)
}
}
if len(discoveredNames) > 0 {
t.registry.MarkDiscovered(discoveredNames...)
}
b, _ := jsonv2.Marshal(results) b, _ := jsonv2.Marshal(results)
return &ToolResult{ForLLM: string(b)} return &ToolResult{ForLLM: string(b)}
} }
// extractSchemaFields pulls the properties map and required list from a
// tool's full JSON Schema parameters object.
func extractSchemaFields(params map[string]interface{}) (map[string]interface{}, []string) {
props, hasProps := params["properties"].(map[string]interface{})
if !hasProps {
return nil, nil
}
var required []string
switch r := params["required"].(type) {
case []string:
required = r
case []interface{}:
for _, v := range r {
if s, ok := v.(string); ok {
required = append(required, s)
}
}
}
return props, required
}
func (t *ToolSearchTool) listAll() *ToolResult { func (t *ToolSearchTool) listAll() *ToolResult {
var results []toolSearchResult var results []toolSearchResult
@ -127,10 +164,13 @@ func (t *ToolSearchTool) listAll() *ToolResult {
if tool.Name() == "tool_search" || tool.Name() == "tool_call" { if tool.Name() == "tool_search" || tool.Name() == "tool_call" {
continue continue
} }
params, required := extractSchemaFields(tool.Parameters())
results = append(results, toolSearchResult{ results = append(results, toolSearchResult{
Name: tool.Name(), Name: tool.Name(),
Description: tool.Description(), Description: tool.Description(),
Kind: "tool", Kind: "tool",
Parameters: params,
Required: required,
}) })
} }
t.registry.mu.RUnlock() t.registry.mu.RUnlock()
@ -151,6 +191,17 @@ func (t *ToolSearchTool) listAll() *ToolResult {
return results[i].Name < results[j].Name return results[i].Name < results[j].Name
}) })
// Record all tool names for promotion
discoveredNames := make([]string, 0)
for _, r := range results {
if r.Kind == "tool" {
discoveredNames = append(discoveredNames, r.Name)
}
}
if len(discoveredNames) > 0 {
t.registry.MarkDiscovered(discoveredNames...)
}
b, _ := jsonv2.Marshal(results) b, _ := jsonv2.Marshal(results)
return &ToolResult{ForLLM: string(b)} return &ToolResult{ForLLM: string(b)}
} }

View file

@ -397,6 +397,104 @@ func TestToolToSchema_WithoutExamples(t *testing.T) {
} }
} }
// --- Discovery tracking tests ---
func TestToolSearchTool_DiscoveryTracking(t *testing.T) {
r := NewToolRegistry()
r.Register(&stubToolWithSchema{name: "edit_file", desc: "Edit a file"})
r.Register(&stubToolWithSchema{name: "web_search", desc: "Search the web"})
s := NewToolSearchTool(r)
s.Execute(context.Background(), map[string]interface{}{"query": "edit"})
discovered := r.DrainDiscovered()
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered tool, got %d", len(discovered))
}
if discovered[0].Name() != "edit_file" {
t.Errorf("expected edit_file, got %s", discovered[0].Name())
}
// Second drain should be empty
discovered2 := r.DrainDiscovered()
if len(discovered2) != 0 {
t.Errorf("expected 0 after drain, got %d", len(discovered2))
}
}
func TestToolSearchTool_DiscoverySkipsGateway(t *testing.T) {
r := NewToolRegistry()
r.Register(&stubToolWithSchema{name: "read_file", desc: "Read a file"})
r.MarkGateway("read_file")
s := NewToolSearchTool(r)
s.Execute(context.Background(), map[string]interface{}{"query": "read"})
discovered := r.DrainDiscovered()
if len(discovered) != 0 {
t.Errorf("gateway tools should not appear in discovered set, got %d", len(discovered))
}
}
func TestToolSearchTool_DiscoverySkipsMetaTools(t *testing.T) {
r := NewToolRegistry()
r.RegisterMetaTools()
s, _ := r.Get("tool_search")
s.Execute(context.Background(), map[string]interface{}{})
discovered := r.DrainDiscovered()
for _, d := range discovered {
if d.Name() == "tool_search" || d.Name() == "tool_call" {
t.Errorf("meta-tool %s should not be in discovered set", d.Name())
}
}
}
// --- Schema in search results tests ---
func TestToolSearchTool_ReturnsSchema(t *testing.T) {
r := NewToolRegistry()
r.Register(&stubToolWithSchema{name: "read_file", desc: "Read a file"})
s := NewToolSearchTool(r)
result := s.Execute(context.Background(), map[string]interface{}{"query": "read"})
var results []toolSearchResult
jsonv2.Unmarshal([]byte(result.ForLLM), &results)
if len(results) == 0 {
t.Fatal("expected at least one result")
}
if results[0].Parameters == nil {
t.Error("expected parameters in search result")
}
if _, ok := results[0].Parameters["path"]; !ok {
t.Error("expected 'path' in parameters")
}
if len(results[0].Required) == 0 || results[0].Required[0] != "path" {
t.Errorf("expected required=['path'], got %v", results[0].Required)
}
}
func TestToolSearchTool_ListAll_ReturnsSchema(t *testing.T) {
r := NewToolRegistry()
r.Register(&stubToolWithSchema{name: "read_file", desc: "Read a file"})
s := NewToolSearchTool(r)
result := s.Execute(context.Background(), map[string]interface{}{})
var results []toolSearchResult
jsonv2.Unmarshal([]byte(result.ForLLM), &results)
if len(results) == 0 {
t.Fatal("expected at least one result")
}
if results[0].Parameters == nil {
t.Error("expected parameters in listAll result")
}
}
// --- stubTool for testing --- // --- stubTool for testing ---
type stubTool struct { type stubTool struct {
name string name string
@ -410,6 +508,30 @@ func (s *stubTool) Execute(_ context.Context, args map[string]interface{}) *Tool
return &ToolResult{ForLLM: "executed " + s.name} return &ToolResult{ForLLM: "executed " + s.name}
} }
// stubToolWithSchema returns a realistic JSON Schema parameters object.
type stubToolWithSchema struct {
name string
desc string
}
func (s *stubToolWithSchema) Name() string { return s.name }
func (s *stubToolWithSchema) Description() string { return s.desc }
func (s *stubToolWithSchema) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "Absolute path to the file",
},
},
"required": []string{"path"},
}
}
func (s *stubToolWithSchema) Execute(_ context.Context, args map[string]interface{}) *ToolResult {
return &ToolResult{ForLLM: "executed " + s.name}
}
type stubToolWithExamples struct { type stubToolWithExamples struct {
stubTool stubTool
examples []map[string]interface{} examples []map[string]interface{}