feat(0144c): launcher wiring + adapter al tool-use loop LLM

Schema DeviceMeshConfig en AgentConfig. Adapter ToolsForLLM convierte
ToolSpec → tools.Tool transparente al LLM existente. URL via env var
override. tools_allowed filter. agent-wsl-lucas blank import en launcher.

LLM ve los tools como cualquier otra herramienta. Effects runner ya
soporta ActionKindDeviceMesh como fallback. Build + tests verdes.
This commit is contained in:
2026-05-24 14:07:13 +02:00
parent 4c5bf95def
commit 61606d450d
6 changed files with 457 additions and 2 deletions
+1
View File
@@ -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"
)
+104
View File
@@ -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/<id> > <config-dir>/data
func resolveDataBase(cfg *config.AgentConfig) string {
+144
View File
@@ -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")
}
+16 -2
View File
@@ -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)
+81
View File
@@ -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 {
+111
View File
@@ -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")
}
}