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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user