feat(tools): add ResourceProvider interface and tool_call resource injection

Tools can now implement ResourceProvider to declare lazy-loaded resources
(schemas, examples, configs). tool_call meta-tool loads resources before
dispatch and injects them via context, enabling on-demand resource fetching
without increasing registration-time memory footprint.
This commit is contained in:
ZanzyTHEbar 2026-02-18 13:06:22 +00:00
parent b6ed552089
commit cf7952f2c5
3 changed files with 122 additions and 1 deletions

View file

@ -69,6 +69,27 @@ type AsyncTool interface {
SetCallback(cb AsyncCallback) SetCallback(cb AsyncCallback)
} }
// ResourceProvider is an optional interface that tools can implement to declare
// resources they need loaded before execution (schemas, examples, docs, configs).
//
// When a tool implements this interface, the tool_call meta-tool will call
// LoadResources before dispatching the tool execution, injecting the loaded
// resources into the execution context.
//
// This enables lazy loading: resources are only fetched when the tool is
// actually invoked, not when it's registered, saving memory and startup time.
type ResourceProvider interface {
// ResourceKeys returns identifiers for resources this tool needs.
// Keys are opaque strings meaningful to the tool (e.g., "schema:users",
// "example:basic", "config:limits").
ResourceKeys() []string
// LoadResources fetches the declared resources and returns them as a map
// of key -> content. Called once per tool_call dispatch. The tool can
// cache internally if needed.
LoadResources(ctx context.Context) (map[string]string, error)
}
func ToolToSchema(tool Tool) map[string]interface{} { func ToolToSchema(tool Tool) map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"type": "function", "type": "function",

View file

@ -4,8 +4,20 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/sipeed/picoclaw/pkg/logger"
) )
type ctxKeyResources struct{}
// ResourcesFromContext extracts loaded resources injected by tool_call before dispatch.
func ResourcesFromContext(ctx context.Context) map[string]string {
if v, ok := ctx.Value(ctxKeyResources{}).(map[string]string); ok {
return v
}
return nil
}
// ToolCallTool is a meta-tool that dispatches to any registered tool by name. // ToolCallTool is a meta-tool that dispatches to any registered tool by name.
// This enables progressive disclosure: instead of exposing all tools to the LLM, // This enables progressive disclosure: instead of exposing all tools to the LLM,
// only tool_search and tool_call are exposed. The agent discovers tools via // only tool_search and tool_call are exposed. The agent discovers tools via
@ -77,6 +89,18 @@ func (t *ToolCallTool) Execute(ctx context.Context, args map[string]interface{})
return ErrorResult(fmt.Sprintf("arguments must be a JSON object, got %T", v)) return ErrorResult(fmt.Sprintf("arguments must be a JSON object, got %T", v))
} }
// Dispatch to the target tool via the registry // If the target tool declares resources, load them before dispatch.
if tool, found := t.registry.Get(toolName); found {
if rp, ok := tool.(ResourceProvider); ok {
resources, err := rp.LoadResources(ctx)
if err != nil {
logger.WarnCF("tool_call", "Failed to load resources for tool",
map[string]interface{}{"tool": toolName, "error": err.Error()})
} else if len(resources) > 0 {
ctx = context.WithValue(ctx, ctxKeyResources{}, resources)
}
}
}
return t.registry.ExecuteWithContext(ctx, toolName, toolArgs, t.channel, t.chatID, nil) return t.registry.ExecuteWithContext(ctx, toolName, toolArgs, t.channel, t.chatID, nil)
} }

View file

@ -172,8 +172,84 @@ func TestToolCallTool_ContextPropagation(t *testing.T) {
} }
} }
func TestToolCallTool_ResourceProvider_LoadsResources(t *testing.T) {
r := NewToolRegistry()
rt := &resourceAwareTool{
resources: map[string]string{
"schema:users": `{"name": "string", "age": "int"}`,
},
}
r.Register(rt)
tc := NewToolCallTool(r)
result := tc.Execute(context.Background(), map[string]interface{}{
"tool_name": "resource_tool",
"arguments": map[string]interface{}{},
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
// The tool should have received resources via context
if rt.receivedResources == nil {
t.Fatal("expected resources to be injected via context")
}
if rt.receivedResources["schema:users"] == "" {
t.Error("expected 'schema:users' resource")
}
}
func TestToolCallTool_NoResourceProvider_StillWorks(t *testing.T) {
r := NewToolRegistry()
r.Register(&stubTool{name: "plain", desc: "No resources"})
tc := NewToolCallTool(r)
result := tc.Execute(context.Background(), map[string]interface{}{
"tool_name": "plain",
"arguments": map[string]interface{}{},
})
if result.IsError {
t.Errorf("unexpected error: %s", result.ForLLM)
}
}
func TestResourcesFromContext_Empty(t *testing.T) {
res := ResourcesFromContext(context.Background())
if res != nil {
t.Error("expected nil for empty context")
}
}
// --- test helpers --- // --- test helpers ---
// resourceAwareTool implements both Tool and ResourceProvider
type resourceAwareTool struct {
resources map[string]string
receivedResources map[string]string
}
func (r *resourceAwareTool) Name() string { return "resource_tool" }
func (r *resourceAwareTool) Description() string { return "Tool with resources" }
func (r *resourceAwareTool) Parameters() map[string]interface{} {
return map[string]interface{}{}
}
func (r *resourceAwareTool) ResourceKeys() []string {
keys := make([]string, 0, len(r.resources))
for k := range r.resources {
keys = append(keys, k)
}
return keys
}
func (r *resourceAwareTool) LoadResources(_ context.Context) (map[string]string, error) {
return r.resources, nil
}
func (r *resourceAwareTool) Execute(ctx context.Context, _ map[string]interface{}) *ToolResult {
r.receivedResources = ResourcesFromContext(ctx)
return &ToolResult{ForLLM: "ok"}
}
type echoTool struct{} type echoTool struct{}
func (e *echoTool) Name() string { return "echo" } func (e *echoTool) Name() string { return "echo" }