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:
+55
-3
@@ -3,15 +3,27 @@ package effects
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
"github.com/enmanuel/agents/pkg/tools/devicemesh"
|
||||
"github.com/enmanuel/agents/shell/logger"
|
||||
"github.com/enmanuel/agents/shell/ssh"
|
||||
)
|
||||
|
||||
// DeviceMeshCaller is the minimal interface that the Runner needs from a
|
||||
// devicemesh.ToolRegistry. It is an interface (rather than a concrete type)
|
||||
// so tests can mock without spinning up an HTTP server.
|
||||
type DeviceMeshCaller interface {
|
||||
Call(ctx context.Context, toolName string, input map[string]any) (any, error)
|
||||
}
|
||||
|
||||
// Compile-time check: the real registry satisfies the interface.
|
||||
var _ DeviceMeshCaller = (*devicemesh.ToolRegistry)(nil)
|
||||
|
||||
// Result holds the outcome of executing a single action.
|
||||
type Result struct {
|
||||
Action decision.Action
|
||||
@@ -32,16 +44,27 @@ type MatrixSender interface {
|
||||
|
||||
// Runner interprets actions and executes them.
|
||||
type Runner struct {
|
||||
matrix MatrixSender
|
||||
ssh *ssh.Executor
|
||||
logger *slog.Logger
|
||||
matrix MatrixSender
|
||||
ssh *ssh.Executor
|
||||
deviceMesh DeviceMeshCaller
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewRunner creates a Runner with the provided dependencies.
|
||||
// The device mesh tool registry is left nil; ActionKindDeviceMesh actions
|
||||
// will be rejected with a clear error. Use NewRunnerWithDeviceMesh to wire
|
||||
// the mesh caller.
|
||||
func NewRunner(matrix MatrixSender, ssh *ssh.Executor, logger *slog.Logger) *Runner {
|
||||
return &Runner{matrix: matrix, ssh: ssh, logger: logger}
|
||||
}
|
||||
|
||||
// NewRunnerWithDeviceMesh wires a Runner with a DeviceMeshCaller, enabling
|
||||
// ActionKindDeviceMesh dispatch. Used by the launcher when an agent has
|
||||
// cfg.DeviceMesh.Enabled = true (wiring lives in 0144c).
|
||||
func NewRunnerWithDeviceMesh(matrix MatrixSender, ssh *ssh.Executor, dm DeviceMeshCaller, logger *slog.Logger) *Runner {
|
||||
return &Runner{matrix: matrix, ssh: ssh, deviceMesh: dm, logger: logger}
|
||||
}
|
||||
|
||||
// Execute runs each action sequentially and returns results.
|
||||
func (r *Runner) Execute(ctx context.Context, roomID string, actions []decision.Action) []Result {
|
||||
r.logger.Debug("effects_batch", "room", roomID, "count", len(actions))
|
||||
@@ -89,7 +112,36 @@ func (r *Runner) executeOne(ctx context.Context, roomID string, a decision.Actio
|
||||
}
|
||||
return Result{Action: a, Output: output, Err: res.Err}
|
||||
|
||||
case decision.ActionKindDeviceMesh:
|
||||
if a.DeviceMesh == nil {
|
||||
return Result{Action: a, Err: fmt.Errorf("nil device_mesh action")}
|
||||
}
|
||||
if r.deviceMesh == nil {
|
||||
return Result{Action: a, Err: fmt.Errorf("device_mesh action received but Runner has no DeviceMeshCaller (build with NewRunnerWithDeviceMesh)")}
|
||||
}
|
||||
result, err := r.deviceMesh.Call(ctx, a.DeviceMesh.Tool, a.DeviceMesh.Input)
|
||||
output := formatDeviceMeshResult(result)
|
||||
return Result{Action: a, Output: output, Err: err}
|
||||
|
||||
default:
|
||||
return Result{Action: a, Err: fmt.Errorf("unhandled action kind: %s", a.Kind)}
|
||||
}
|
||||
}
|
||||
|
||||
// formatDeviceMeshResult renders the tool result as a stable JSON string
|
||||
// suitable for embedding in a tool_result message to the LLM. Errors during
|
||||
// marshaling collapse to a printable Go representation — never panic, never
|
||||
// drop data on the floor.
|
||||
func formatDeviceMeshResult(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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package effects
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
)
|
||||
|
||||
// stubMeshCaller is a minimal DeviceMeshCaller for runner tests.
|
||||
type stubMeshCaller struct {
|
||||
tool string
|
||||
input map[string]any
|
||||
result any
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubMeshCaller) Call(_ context.Context, toolName string, input map[string]any) (any, error) {
|
||||
s.tool = toolName
|
||||
s.input = input
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
func newSilentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestRunner_DeviceMesh_Success(t *testing.T) {
|
||||
stub := &stubMeshCaller{result: map[string]any{"stdout": "hello", "exit_code": 0}}
|
||||
r := NewRunnerWithDeviceMesh(nil, nil, stub, newSilentLogger())
|
||||
|
||||
results := r.Execute(context.Background(), "!room", []decision.Action{{
|
||||
Kind: decision.ActionKindDeviceMesh,
|
||||
DeviceMesh: &decision.DeviceMeshAction{
|
||||
Tool: "exec",
|
||||
Input: map[string]any{"argv": []string{"echo", "hello"}},
|
||||
},
|
||||
}})
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
res := results[0]
|
||||
if res.Err != nil {
|
||||
t.Fatalf("expected no error, got %v", res.Err)
|
||||
}
|
||||
if stub.tool != "exec" {
|
||||
t.Errorf("stub.tool=%q", stub.tool)
|
||||
}
|
||||
if !strings.Contains(res.Output, "hello") {
|
||||
t.Errorf("output missing 'hello': %q", res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, "exit_code") {
|
||||
t.Errorf("output should be JSON containing exit_code: %q", res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_DeviceMesh_PropagatesError(t *testing.T) {
|
||||
stub := &stubMeshCaller{err: errors.New("approval timeout")}
|
||||
r := NewRunnerWithDeviceMesh(nil, nil, stub, newSilentLogger())
|
||||
results := r.Execute(context.Background(), "!room", []decision.Action{{
|
||||
Kind: decision.ActionKindDeviceMesh,
|
||||
DeviceMesh: &decision.DeviceMeshAction{Tool: "pkg.install", Input: map[string]any{"name": "jq"}},
|
||||
}})
|
||||
if results[0].Err == nil {
|
||||
t.Fatalf("expected error to propagate")
|
||||
}
|
||||
if !strings.Contains(results[0].Err.Error(), "approval") {
|
||||
t.Errorf("error mismatch: %v", results[0].Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_DeviceMesh_NilAction(t *testing.T) {
|
||||
r := NewRunnerWithDeviceMesh(nil, nil, &stubMeshCaller{}, newSilentLogger())
|
||||
results := r.Execute(context.Background(), "!room", []decision.Action{{
|
||||
Kind: decision.ActionKindDeviceMesh,
|
||||
// DeviceMesh field is nil
|
||||
}})
|
||||
if results[0].Err == nil {
|
||||
t.Fatalf("expected error for nil DeviceMesh field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_DeviceMesh_NoCaller(t *testing.T) {
|
||||
// Using NewRunner (legacy) — should fail gracefully on DeviceMesh action.
|
||||
r := NewRunner(nil, nil, newSilentLogger())
|
||||
results := r.Execute(context.Background(), "!room", []decision.Action{{
|
||||
Kind: decision.ActionKindDeviceMesh,
|
||||
DeviceMesh: &decision.DeviceMeshAction{Tool: "exec", Input: map[string]any{"argv": []string{"x"}}},
|
||||
}})
|
||||
if results[0].Err == nil {
|
||||
t.Fatalf("expected error when Runner has no DeviceMeshCaller")
|
||||
}
|
||||
if !strings.Contains(results[0].Err.Error(), "DeviceMeshCaller") {
|
||||
t.Errorf("error should mention DeviceMeshCaller: %v", results[0].Err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user