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:
parent
b6ed552089
commit
cf7952f2c5
3 changed files with 122 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue