diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go index c52eb31..fdfe2bc 100644 --- a/cmd/launcher/main.go +++ b/cmd/launcher/main.go @@ -40,6 +40,7 @@ import ( _ "github.com/enmanuel/agents/agents/wikipedia-bot" _ "github.com/enmanuel/agents/agents/exchange-bot" _ "github.com/enmanuel/agents/agents/reminder-bot" + _ "github.com/enmanuel/agents/agents/agent-wsl-lucas" testbot "github.com/enmanuel/agents/agents/test-bot" ) diff --git a/devagents/registry_build.go b/devagents/registry_build.go index 6209ca8..78ae7b5 100644 --- a/devagents/registry_build.go +++ b/devagents/registry_build.go @@ -9,6 +9,7 @@ import ( "github.com/enmanuel/agents/internal/config" "github.com/enmanuel/agents/pkg/memory" + devicemeshtools "github.com/enmanuel/agents/pkg/tools/devicemesh" shellknowledge "github.com/enmanuel/agents/shell/knowledge" shellmcp "github.com/enmanuel/agents/shell/mcp" shellskills "github.com/enmanuel/agents/shell/skills" @@ -291,9 +292,112 @@ func buildToolRegistry( logger.Debug("registered skills tools") } + // Device-mesh tools — exposed when the agent's config has a populated + // `device_mesh:` block with enabled=true. The builtin catalog (issue 0144 + // §2.1) is filtered by Mode and then narrowed by ToolsAllowed; each + // surviving spec is adapted to a tools.Tool whose Exec routes through + // the devicemesh.ToolRegistry (validate → ArgMapping → HTTP dispatch → + // ResultMapping). See pkg/tools/devicemesh/adapter.go. + if dmReg := buildDeviceMeshRegistry(cfg, logger); dmReg != nil { + for _, t := range devicemeshtools.ToolsForLLM(dmReg) { + reg.Register(t) + } + logger.Info("device_mesh tools registered", + "host", cfg.DeviceMesh.ResolvedHost(), + "mode", normalizeMeshMode(cfg.DeviceMesh.Mode), + "count", dmReg.Len(), + "names", dmReg.Names(), + ) + } + return reg } +// buildDeviceMeshRegistry constructs the per-agent devicemesh.ToolRegistry +// from cfg.DeviceMesh and returns it ready to be adapted. Returns nil when +// the block is absent, disabled, or yields zero tools so the caller can +// skip registration cleanly. Pure(-ish) — only side effect is os.Getenv +// for the URL override; the rest is pure data shuffling. +func buildDeviceMeshRegistry(cfg *config.AgentConfig, logger *slog.Logger) *devicemeshtools.ToolRegistry { + if cfg == nil || cfg.DeviceMesh == nil || !cfg.DeviceMesh.Enabled { + return nil + } + dm := cfg.DeviceMesh + + // Resolve the device_agent URL: env override wins when present and + // non-empty; otherwise fall back to the literal URL from YAML. This + // keeps endpoints out of git while staying explicit. + url := dm.DeviceAgentURL + if dm.URLEnv != "" { + if v := os.Getenv(dm.URLEnv); v != "" { + url = v + } + } + if url == "" { + logger.Warn("device_mesh enabled but no URL resolved (neither device_agent_url nor URLEnv)", + "url_env", dm.URLEnv, + "host", dm.ResolvedHost(), + ) + return nil + } + + client := devicemeshtools.NewClient(url) + if t := dm.ResolvedTimeoutSeconds(); t > 0 { + client.Timeout = time.Duration(t) * time.Second + } + + mode := normalizeMeshMode(dm.Mode) + reg := devicemeshtools.NewToolRegistry(client) + registered := devicemeshtools.RegisterBuiltins(reg, mode) + logger.Debug("device_mesh builtins registered", "mode", mode, "count", len(registered), "names", registered) + + // Narrow by tools_allowed if the config asks for it. The filter is a + // pure transform — same Client, fewer specs. + if len(dm.ToolsAllowed) > 0 { + filtered := devicemeshtools.FilterByAllowed(reg, dm.ToolsAllowed) + // Warn on names that the config asked for but the catalog does not + // provide — typical drift between template and code after a new + // builtin lands. + present := make(map[string]bool, len(registered)) + for _, n := range registered { + present[n] = true + } + for _, n := range dm.ToolsAllowed { + if !present[n] { + logger.Warn("device_mesh tools_allowed lists unknown tool", + "name", n, + "mode", mode, + ) + } + } + reg = filtered + } + + if reg.Len() == 0 { + logger.Warn("device_mesh registry empty after filter — skipping", + "host", dm.ResolvedHost(), + ) + return nil + } + return reg +} + +// normalizeMeshMode maps the YAML "mode" string to the RegistrationMode +// enum, defaulting to ModeUser. Pure function — used by both the registry +// builder and tests. +func normalizeMeshMode(s string) devicemeshtools.RegistrationMode { + switch s { + case "sudo": + return devicemeshtools.ModeSudo + case "all": + return devicemeshtools.ModeAll + case "user", "": + return devicemeshtools.ModeUser + default: + return devicemeshtools.ModeUser + } +} + // resolveDataBase returns the base directory for agent runtime data. // Priority: config storage.base_path > $AGENTS_DATA_DIR/ > /data func resolveDataBase(cfg *config.AgentConfig) string { diff --git a/devagents/registry_build_test.go b/devagents/registry_build_test.go index af3f967..845363d 100644 --- a/devagents/registry_build_test.go +++ b/devagents/registry_build_test.go @@ -171,3 +171,147 @@ func assertToolNotRegistered(t *testing.T, reg interface{ Names() []string }, na } } } + +func TestBuildToolRegistry_DeviceMeshDisabled(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + cfg := &config.AgentConfig{ + Agent: config.AgentMeta{ID: "test-agent"}, + DeviceMesh: nil, + } + roomCtx := &toolmemory.RoomContext{} + + reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger) + + // None of the device_mesh tool names should appear when the block is nil. + assertToolNotRegistered(t, reg, "exec") + assertToolNotRegistered(t, reg, "shell.eval") + assertToolNotRegistered(t, reg, "fs.read") +} + +func TestBuildDeviceMeshRegistry_NoURLReturnsNil(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + cfg := &config.AgentConfig{ + Agent: config.AgentMeta{ID: "agent-x"}, + DeviceMesh: &config.DeviceMeshConfig{ + Enabled: true, + Mode: "user", + // no URL, no URLEnv + }, + } + if got := buildDeviceMeshRegistry(cfg, logger); got != nil { + t.Errorf("expected nil registry when no URL is set, got %d tools", got.Len()) + } +} + +func TestBuildDeviceMeshRegistry_URLEnvOverride(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + t.Setenv("TEST_DM_URL", "http://10.42.0.99:7474") + + cfg := &config.AgentConfig{ + Agent: config.AgentMeta{ID: "agent-x"}, + DeviceMesh: &config.DeviceMeshConfig{ + Enabled: true, + Mode: "user", + DeviceAgentURL: "http://stale-url", + URLEnv: "TEST_DM_URL", + }, + } + reg := buildDeviceMeshRegistry(cfg, logger) + if reg == nil { + t.Fatalf("expected non-nil registry") + } + if reg.Client().BaseURL != "http://10.42.0.99:7474" { + t.Errorf("URLEnv override failed: got %q", reg.Client().BaseURL) + } +} + +func TestBuildDeviceMeshRegistry_UserModeFiltersApproval(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + cfg := &config.AgentConfig{ + Agent: config.AgentMeta{ID: "agent-x"}, + DeviceMesh: &config.DeviceMeshConfig{ + Enabled: true, + Mode: "user", + DeviceAgentURL: "http://dummy:7474", + }, + } + reg := buildDeviceMeshRegistry(cfg, logger) + if reg == nil { + t.Fatalf("expected non-nil registry") + } + for _, n := range reg.Names() { + // User mode: pkg.install (requires approval) must not be present. + if n == "pkg.install" { + t.Errorf("user mode leaked approval-only tool: %s", n) + } + } +} + +func TestBuildDeviceMeshRegistry_SudoModeKeepsOnlyApproval(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + cfg := &config.AgentConfig{ + Agent: config.AgentMeta{ID: "agent-x-sudo"}, + DeviceMesh: &config.DeviceMeshConfig{ + Enabled: true, + Mode: "sudo", + DeviceAgentURL: "http://dummy:7474", + }, + } + reg := buildDeviceMeshRegistry(cfg, logger) + if reg == nil { + t.Fatalf("expected non-nil registry") + } + // pkg.install MUST be there in sudo mode. + assertToolRegistered(t, reg, "pkg.install") + // shell.eval is always registered (special-cased) and promoted to approval. + spec, ok := reg.Get("shell.eval") + if !ok { + t.Fatalf("shell.eval should be registered in sudo mode too") + } + if !spec.RequiresApproval { + t.Errorf("shell.eval in sudo mode should have RequiresApproval=true") + } +} + +func TestBuildDeviceMeshRegistry_ToolsAllowedNarrows(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + cfg := &config.AgentConfig{ + Agent: config.AgentMeta{ID: "agent-x"}, + DeviceMesh: &config.DeviceMeshConfig{ + Enabled: true, + Mode: "user", + DeviceAgentURL: "http://dummy:7474", + ToolsAllowed: []string{"exec", "fs.read", "zzz.unknown"}, + }, + } + reg := buildDeviceMeshRegistry(cfg, logger) + if reg == nil { + t.Fatalf("expected non-nil registry") + } + if reg.Len() != 2 { + t.Errorf("expected 2 tools after filter, got %d: %v", reg.Len(), reg.Names()) + } + assertToolRegistered(t, reg, "exec") + assertToolRegistered(t, reg, "fs.read") +} + +func TestBuildToolRegistry_DeviceMeshAdaptedIntoMainRegistry(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + cfg := &config.AgentConfig{ + Agent: config.AgentMeta{ID: "agent-x"}, + DeviceMesh: &config.DeviceMeshConfig{ + Enabled: true, + Mode: "user", + DeviceAgentURL: "http://dummy:7474", + ToolsAllowed: []string{"exec"}, + }, + } + roomCtx := &toolmemory.RoomContext{} + + reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger) + + // The "exec" tool should appear in the main agent tool registry, alongside + // the always-on tools, ready for the LLM tool-use loop to invoke. + assertToolRegistered(t, reg, "exec") + assertToolRegistered(t, reg, "current_time") +} diff --git a/devagents/runtime.go b/devagents/runtime.go index bc2ebbc..3a44825 100644 --- a/devagents/runtime.go +++ b/devagents/runtime.go @@ -22,6 +22,7 @@ import ( "github.com/enmanuel/agents/pkg/memory" "github.com/enmanuel/agents/pkg/personality" "github.com/enmanuel/agents/pkg/sanitize" + devicemeshtools "github.com/enmanuel/agents/pkg/tools/devicemesh" "github.com/enmanuel/agents/shell/audit" "github.com/enmanuel/agents/shell/bus" shellcron "github.com/enmanuel/agents/shell/cron" @@ -140,8 +141,21 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, agentACL acl.ACL, logge return nil, err } - // Effects runner - runner := effects.NewRunner(matrixClient, sshExec, logger) + // Effects runner — wire the device_mesh registry when the agent config + // enables it, so decision.ActionKindDeviceMesh actions dispatched by the + // rules layer can reach the remote device_agent. The LLM tool-use loop + // goes through tools.Registry (see buildToolRegistry below), but the + // Action-emitting path needs its own handle to the same registry. + var dmRegForRunner *devicemeshtools.ToolRegistry + if cfg.DeviceMesh != nil && cfg.DeviceMesh.Enabled { + dmRegForRunner = buildDeviceMeshRegistry(cfg, logger) + } + var runner *effects.Runner + if dmRegForRunner != nil { + runner = effects.NewRunnerWithDeviceMesh(matrixClient, sshExec, dmRegForRunner, logger) + } else { + runner = effects.NewRunner(matrixClient, sshExec, logger) + } // Resolve base data path for this agent dataBase := resolveDataBase(cfg) diff --git a/internal/config/schema.go b/internal/config/schema.go index 73b750a..11051c7 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -17,12 +17,93 @@ type AgentConfig struct { Memory MemoryCfg `yaml:"memory"` Skills SkillsCfg `yaml:"skills"` + // DeviceMesh holds the optional device-mesh block. When nil the agent has + // no device_mesh tools; when set and Enabled the runtime constructs a + // devicemesh.Client + ToolRegistry and registers the builtin tools (filtered + // by ToolsAllowed). See issue 0144 §6.1 + .claude/rules/cpp_apps.md. + DeviceMesh *DeviceMeshConfig `yaml:"device_mesh,omitempty"` + // ConfigDir is the directory containing the config file. Set by the loader // at load time, not from YAML. Used to resolve relative paths like // system_prompt_file correctly regardless of where the agent lives. ConfigDir string `yaml:"-"` } +// DeviceMeshConfig is the optional device-mesh block on the agent config. +// When DeviceMesh is non-nil and Enabled is true, the launcher builds a +// devicemesh.Client + ToolRegistry, registers builtin tools filtered by +// Mode (user|sudo), optionally narrows them via ToolsAllowed, and exposes +// each tool to the LLM tool-use loop via the standard tool registry. +type DeviceMeshConfig struct { + // Enabled gates the whole block. False keeps it inert even when present. + Enabled bool `yaml:"enabled"` + + // Host identifies the target device for log/audit context. Matches + // device_id from the manifest (ex "home-wsl", "aurgi-pc"). + Host string `yaml:"host"` + + // DeviceID is an alias for Host. Templates use device_id; keep both for + // compatibility. When both are set Host wins. + DeviceID string `yaml:"device_id,omitempty"` + + // Mode controls which subset of the builtin catalog gets registered. + // "user" → non-approval tools. "sudo" → approval-gated tools (shell.eval + // promoted to requires_approval). Empty defaults to "user". + Mode string `yaml:"mode"` + + // DeviceAgentURL is the http://host:port URL of the remote device_agent. + // May be empty when URLEnv is set. + DeviceAgentURL string `yaml:"device_agent_url"` + + // URLEnv allows the agent_url to be supplied at runtime via env var + // (ex "AGENT_HOME_WSL_DEVICE_MESH_URL"). When non-empty the runtime reads + // the env var; if both are set, the env var wins when non-empty. This + // keeps device URLs out of the YAML/git history. + URLEnv string `yaml:"device_agent_url_env,omitempty"` + + // ManifestID is metadata for log/audit context. The device_agent enforces + // the actual manifest binding. Empty allowed. + ManifestID string `yaml:"manifest_id,omitempty"` + + // ToolsAllowed is a whitelist applied AFTER RegisterBuiltins. Empty means + // "keep all tools the mode-filter accepted". Names that do not match any + // registered tool are logged and ignored. + ToolsAllowed []string `yaml:"tools_allowed,omitempty"` + + // TimeoutSeconds overrides the per-call HTTP timeout. 0 → DefaultTimeout + // of the devicemesh client (30s). + TimeoutSeconds int `yaml:"timeout_seconds,omitempty"` + + // ClientTimeoutS is an alias for TimeoutSeconds. Templates use + // client_timeout_s; we accept both. When both set, ClientTimeoutS wins + // when non-zero. + ClientTimeoutS int `yaml:"client_timeout_s,omitempty"` +} + +// ResolvedHost returns Host if non-empty, otherwise DeviceID. Used by the +// runtime to log audit context without caring which key the YAML used. +func (d *DeviceMeshConfig) ResolvedHost() string { + if d == nil { + return "" + } + if d.Host != "" { + return d.Host + } + return d.DeviceID +} + +// ResolvedTimeoutSeconds returns the first non-zero of TimeoutSeconds and +// ClientTimeoutS. 0 means "use devicemesh defaults". +func (d *DeviceMeshConfig) ResolvedTimeoutSeconds() int { + if d == nil { + return 0 + } + if d.TimeoutSeconds > 0 { + return d.TimeoutSeconds + } + return d.ClientTimeoutS +} + // ── Identity ────────────────────────────────────────────────────────────── type AgentMeta struct { diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go index 17b1cfb..4398093 100644 --- a/internal/config/schema_test.go +++ b/internal/config/schema_test.go @@ -209,3 +209,114 @@ skills: t.Error("security.sanitize.enabled should be true") } } + +// TestDeviceMeshConfig_Parse verifies that the device_mesh block parses into +// the expected DeviceMeshConfig pointer with both YAML key variants (host vs +// device_id, timeout_seconds vs client_timeout_s, tools_allowed list). +func TestDeviceMeshConfig_Parse(t *testing.T) { + const yamlBody = ` +agent: + id: agent-home-wsl + name: home wsl + enabled: true +matrix: + homeserver: "https://matrix.example.com" + user_id: "@agent-home-wsl:matrix.example.com" +llm: + primary: + provider: anthropic + model: claude-sonnet +device_mesh: + enabled: true + device_id: home-wsl + mode: user + device_agent_url: "http://10.42.0.10:7474" + device_agent_url_env: AGENT_HOME_WSL_DEVICE_MESH_URL + manifest_id: manifest_home-wsl_v1 + client_timeout_s: 60 + tools_allowed: + - exec + - fs.read + - fs.list +` + var cfg AgentConfig + if err := yaml.Unmarshal([]byte(yamlBody), &cfg); err != nil { + t.Fatalf("parse: %v", err) + } + if cfg.DeviceMesh == nil { + t.Fatalf("expected DeviceMesh to be non-nil") + } + dm := cfg.DeviceMesh + if !dm.Enabled { + t.Error("enabled should be true") + } + if dm.DeviceID != "home-wsl" { + t.Errorf("device_id: got %q", dm.DeviceID) + } + if dm.ResolvedHost() != "home-wsl" { + t.Errorf("ResolvedHost(): got %q", dm.ResolvedHost()) + } + if dm.Mode != "user" { + t.Errorf("mode: got %q", dm.Mode) + } + if dm.DeviceAgentURL != "http://10.42.0.10:7474" { + t.Errorf("device_agent_url: got %q", dm.DeviceAgentURL) + } + if dm.URLEnv != "AGENT_HOME_WSL_DEVICE_MESH_URL" { + t.Errorf("device_agent_url_env: got %q", dm.URLEnv) + } + if dm.ManifestID != "manifest_home-wsl_v1" { + t.Errorf("manifest_id: got %q", dm.ManifestID) + } + if dm.ResolvedTimeoutSeconds() != 60 { + t.Errorf("ResolvedTimeoutSeconds(): got %d", dm.ResolvedTimeoutSeconds()) + } + if len(dm.ToolsAllowed) != 3 { + t.Errorf("tools_allowed: got %d entries", len(dm.ToolsAllowed)) + } +} + +// TestDeviceMeshConfig_Absent ensures the field stays nil when the block is +// not present in YAML — the runtime relies on the nil-check to short-circuit. +func TestDeviceMeshConfig_Absent(t *testing.T) { + const yamlBody = ` +agent: + id: plain-bot + enabled: true +matrix: + homeserver: "https://matrix.example.com" + user_id: "@plain-bot:matrix.example.com" +llm: + primary: + provider: openai + model: gpt-4o +` + var cfg AgentConfig + if err := yaml.Unmarshal([]byte(yamlBody), &cfg); err != nil { + t.Fatalf("parse: %v", err) + } + if cfg.DeviceMesh != nil { + t.Errorf("expected nil DeviceMesh, got %+v", cfg.DeviceMesh) + } +} + +// TestDeviceMeshConfig_TimeoutFallback verifies that timeout_seconds is used +// when client_timeout_s is absent. +func TestDeviceMeshConfig_TimeoutFallback(t *testing.T) { + dm := &DeviceMeshConfig{TimeoutSeconds: 45} + if got := dm.ResolvedTimeoutSeconds(); got != 45 { + t.Errorf("expected 45, got %d", got) + } + dm2 := &DeviceMeshConfig{ClientTimeoutS: 90} + if got := dm2.ResolvedTimeoutSeconds(); got != 90 { + t.Errorf("expected 90, got %d", got) + } + // TimeoutSeconds wins when both set. + dm3 := &DeviceMeshConfig{TimeoutSeconds: 30, ClientTimeoutS: 60} + if got := dm3.ResolvedTimeoutSeconds(); got != 30 { + t.Errorf("expected 30, got %d", got) + } + if (*DeviceMeshConfig)(nil).ResolvedTimeoutSeconds() != 0 { + t.Errorf("nil receiver should return 0") + } +}