feat(0144a): tool registry framework para device-mesh
Anade pkg/tools/devicemesh con Client HTTP al device_agent + ToolRegistry con 16 tools standard (exec, fs.*, git.*, docker.*, proc.*, pkg.*, shell.eval). RegisterBuiltins filtra por mode user/sudo via RequiresApproval flag. Hook al pkg/decision con ActionKindDeviceMesh + DeviceMeshAction. Runner soporta dispatch via NewRunnerWithDeviceMesh (back-compat NewRunner). Tests: 25 nuevos en devicemesh + 4 en runner. Build clean.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// adapter.go: bridges devicemesh.ToolSpec → tools.Tool so device-mesh tools
|
||||
// can ride the same registry + LLM tool-use loop that already handles
|
||||
// http/ssh/file/memory tools.
|
||||
//
|
||||
// The agents_and_robots tool stack is:
|
||||
//
|
||||
// tools.Tool { Def: tools.Def{Name, Description, Parameters}, Exec: ToolFunc }
|
||||
// → tools.Registry.Register / ToLLMSpecs / ExecuteForRoom
|
||||
// → devagents/llm.go runLLM tool-use loop
|
||||
//
|
||||
// Device-mesh tools speak a richer language (full JSON-Schema in
|
||||
// InputSchema, capability indirection). The adapter compresses this into the
|
||||
// flatter tools.Param shape that the LLM-side codec already understands,
|
||||
// then routes Exec through ToolRegistry.Call so the schema validator,
|
||||
// ArgMapping, capability dispatch and ResultMapping all still run.
|
||||
//
|
||||
// Pure data + one impure closure: the returned tools.Tool's Exec hits the
|
||||
// network via the embedded Client, but everything outside Exec (Def, Param
|
||||
// extraction) is a pure transform.
|
||||
package devicemesh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/enmanuel/agents/tools"
|
||||
)
|
||||
|
||||
// ToolsForLLM walks the registry and returns one tools.Tool per registered
|
||||
// ToolSpec. Names are alpha-sorted for stable prompt-caching on the LLM side.
|
||||
//
|
||||
// Order matters: the returned slice is what the launcher feeds to
|
||||
// tools.Registry.Register, and the LLM sees the tools in registration order
|
||||
// when ToLLMSpecs() preserves it (it does — registry.Names is sorted).
|
||||
//
|
||||
// Returns an empty slice (never nil) when reg has no tools or is nil.
|
||||
func ToolsForLLM(reg *ToolRegistry) []tools.Tool {
|
||||
if reg == nil {
|
||||
return []tools.Tool{}
|
||||
}
|
||||
specs := reg.List()
|
||||
out := make([]tools.Tool, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
out = append(out, AdaptTool(reg, spec))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdaptTool wraps a single ToolSpec as a tools.Tool. Useful when callers
|
||||
// build a custom subset (ex tests that register one tool and exercise it
|
||||
// through the LLM loop). For the common "register all" case use ToolsForLLM.
|
||||
func AdaptTool(reg *ToolRegistry, spec ToolSpec) tools.Tool {
|
||||
return tools.Tool{
|
||||
Def: tools.Def{
|
||||
Name: spec.Name,
|
||||
Description: enrichDescription(spec),
|
||||
Parameters: paramsFromSchema(spec.InputSchema),
|
||||
},
|
||||
Exec: func(ctx context.Context, args map[string]any) tools.Result {
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
result, err := reg.Call(ctx, spec.Name, args)
|
||||
if err != nil {
|
||||
// Surface approval / validation / dispatch errors verbatim so
|
||||
// the LLM tool-use loop can render them as tool messages and
|
||||
// give the model a chance to self-correct on the next turn.
|
||||
return tools.Result{Err: err}
|
||||
}
|
||||
return tools.Result{Output: formatToolResult(result)}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// enrichDescription appends a one-line marker to the spec description so the
|
||||
// LLM (and any human reading logs) can see at a glance that this tool is
|
||||
// remote and which capability it maps to. The format is stable and short to
|
||||
// avoid bloating the system prompt token budget.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// "Execute a command on the remote device. argv ... [device_mesh: shell.exec]"
|
||||
//
|
||||
// When RequiresApproval is true we also append " (approval required)" so the
|
||||
// model knows the call may be queued / rejected.
|
||||
func enrichDescription(spec ToolSpec) string {
|
||||
desc := spec.Description
|
||||
suffix := fmt.Sprintf(" [device_mesh: %s]", spec.Capability)
|
||||
if spec.RequiresApproval {
|
||||
suffix += " (approval required)"
|
||||
}
|
||||
return desc + suffix
|
||||
}
|
||||
|
||||
// paramsFromSchema flattens a top-level JSON-Schema-lite (the shape device
|
||||
// mesh ToolSpec.InputSchema uses) into the slice of tools.Param the LLM
|
||||
// codec expects. Only the top-level properties are emitted; nested objects
|
||||
// get type "object" and the LLM is told to pass them through verbatim.
|
||||
//
|
||||
// Required fields from the schema's "required" array are reflected onto each
|
||||
// Param. Unknown shapes degrade gracefully — we never panic, we just emit
|
||||
// what we can. Pure function.
|
||||
func paramsFromSchema(schema map[string]any) []tools.Param {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
if len(props) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
requiredSet := make(map[string]bool)
|
||||
if reqRaw, ok := schema["required"]; ok {
|
||||
switch req := reqRaw.(type) {
|
||||
case []string:
|
||||
for _, n := range req {
|
||||
requiredSet[n] = true
|
||||
}
|
||||
case []any:
|
||||
for _, n := range req {
|
||||
if s, ok := n.(string); ok {
|
||||
requiredSet[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort property names to make the output deterministic — ToLLMSpecs sorts
|
||||
// by tool name but does not sort param order; LLMs are sensitive to
|
||||
// reordering when prompt-caching kicks in.
|
||||
names := make([]string, 0, len(props))
|
||||
for n := range props {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
params := make([]tools.Param, 0, len(names))
|
||||
for _, name := range names {
|
||||
propVal, _ := props[name].(map[string]any)
|
||||
p := tools.Param{
|
||||
Name: name,
|
||||
Required: requiredSet[name],
|
||||
}
|
||||
if propVal != nil {
|
||||
if t, ok := propVal["type"].(string); ok {
|
||||
p.Type = t
|
||||
}
|
||||
if d, ok := propVal["description"].(string); ok {
|
||||
p.Description = d
|
||||
}
|
||||
}
|
||||
if p.Type == "" {
|
||||
p.Type = "string"
|
||||
}
|
||||
params = append(params, p)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// formatToolResult renders the device_agent's reply as the JSON string that
|
||||
// gets shoved into the role='tool' message of the LLM transcript.
|
||||
//
|
||||
// - nil → ""
|
||||
// - string → returned as-is (avoids double-encoding)
|
||||
// - everything else → json.Marshal; on marshal failure fall back to a Go
|
||||
// printf so we never drop data on the floor.
|
||||
//
|
||||
// Note: this mirrors shell/effects/runner.go::formatDeviceMeshResult so
|
||||
// ActionKindDeviceMesh and the adapter path produce consistent transcripts.
|
||||
func formatToolResult(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// FilterByAllowed returns a copy of reg containing only tools whose names
|
||||
// appear in the allowed set. Empty allowed → reg returned unchanged. Names
|
||||
// in `allowed` that do not match any tool are silently skipped (the
|
||||
// launcher logs them; this function is pure).
|
||||
//
|
||||
// The returned registry shares the same Client as the source, so dispatches
|
||||
// reach the same device_agent. Re-registering means we keep ArgMapping /
|
||||
// ResultMapping intact — no schema or spec recompute on the hot path.
|
||||
func FilterByAllowed(reg *ToolRegistry, allowed []string) *ToolRegistry {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return reg
|
||||
}
|
||||
allowSet := make(map[string]bool, len(allowed))
|
||||
for _, n := range allowed {
|
||||
allowSet[n] = true
|
||||
}
|
||||
out := NewToolRegistry(reg.Client())
|
||||
for _, spec := range reg.List() {
|
||||
if allowSet[spec.Name] {
|
||||
out.Register(spec)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user