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