Files
agents_and_robots/pkg/tools/devicemesh/adapter.go
T
egutierrez bcd246bf85 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.
2026-05-24 14:07:13 +02:00

213 lines
6.6 KiB
Go

// 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
}