diff --git a/pkg/tools/base.go b/pkg/tools/base.go index b13174633..f3e83b35b 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -69,6 +69,27 @@ type AsyncTool interface { 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{} { return map[string]interface{}{ "type": "function", diff --git a/pkg/tools/call.go b/pkg/tools/call.go index 0054c541a..f1124c8aa 100644 --- a/pkg/tools/call.go +++ b/pkg/tools/call.go @@ -4,8 +4,20 @@ import ( "context" "encoding/json" "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. // 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 @@ -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)) } - // 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) } diff --git a/pkg/tools/call_test.go b/pkg/tools/call_test.go index 090653f44..d357e2c48 100644 --- a/pkg/tools/call_test.go +++ b/pkg/tools/call_test.go @@ -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 --- +// 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{} func (e *echoTool) Name() string { return "echo" }