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)
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue