bcd246bf85
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.
213 lines
6.6 KiB
Go
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
|
|
}
|