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:
@@ -40,6 +40,7 @@ import (
|
|||||||
_ "github.com/enmanuel/agents/agents/wikipedia-bot"
|
_ "github.com/enmanuel/agents/agents/wikipedia-bot"
|
||||||
_ "github.com/enmanuel/agents/agents/exchange-bot"
|
_ "github.com/enmanuel/agents/agents/exchange-bot"
|
||||||
_ "github.com/enmanuel/agents/agents/reminder-bot"
|
_ "github.com/enmanuel/agents/agents/reminder-bot"
|
||||||
|
_ "github.com/enmanuel/agents/agents/agent-wsl-lucas"
|
||||||
testbot "github.com/enmanuel/agents/agents/test-bot"
|
testbot "github.com/enmanuel/agents/agents/test-bot"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/enmanuel/agents/internal/config"
|
"github.com/enmanuel/agents/internal/config"
|
||||||
"github.com/enmanuel/agents/pkg/memory"
|
"github.com/enmanuel/agents/pkg/memory"
|
||||||
|
devicemeshtools "github.com/enmanuel/agents/pkg/tools/devicemesh"
|
||||||
shellknowledge "github.com/enmanuel/agents/shell/knowledge"
|
shellknowledge "github.com/enmanuel/agents/shell/knowledge"
|
||||||
shellmcp "github.com/enmanuel/agents/shell/mcp"
|
shellmcp "github.com/enmanuel/agents/shell/mcp"
|
||||||
shellskills "github.com/enmanuel/agents/shell/skills"
|
shellskills "github.com/enmanuel/agents/shell/skills"
|
||||||
@@ -291,9 +292,112 @@ func buildToolRegistry(
|
|||||||
logger.Debug("registered skills tools")
|
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
|
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.
|
// resolveDataBase returns the base directory for agent runtime data.
|
||||||
// Priority: config storage.base_path > $AGENTS_DATA_DIR/<id> > <config-dir>/data
|
// Priority: config storage.base_path > $AGENTS_DATA_DIR/<id> > <config-dir>/data
|
||||||
func resolveDataBase(cfg *config.AgentConfig) string {
|
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/memory"
|
||||||
"github.com/enmanuel/agents/pkg/personality"
|
"github.com/enmanuel/agents/pkg/personality"
|
||||||
"github.com/enmanuel/agents/pkg/sanitize"
|
"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/audit"
|
||||||
"github.com/enmanuel/agents/shell/bus"
|
"github.com/enmanuel/agents/shell/bus"
|
||||||
shellcron "github.com/enmanuel/agents/shell/cron"
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effects runner
|
// Effects runner — wire the device_mesh registry when the agent config
|
||||||
runner := effects.NewRunner(matrixClient, sshExec, logger)
|
// 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
|
// Resolve base data path for this agent
|
||||||
dataBase := resolveDataBase(cfg)
|
dataBase := resolveDataBase(cfg)
|
||||||
|
|||||||
@@ -17,12 +17,93 @@ type AgentConfig struct {
|
|||||||
Memory MemoryCfg `yaml:"memory"`
|
Memory MemoryCfg `yaml:"memory"`
|
||||||
Skills SkillsCfg `yaml:"skills"`
|
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
|
// ConfigDir is the directory containing the config file. Set by the loader
|
||||||
// at load time, not from YAML. Used to resolve relative paths like
|
// at load time, not from YAML. Used to resolve relative paths like
|
||||||
// system_prompt_file correctly regardless of where the agent lives.
|
// system_prompt_file correctly regardless of where the agent lives.
|
||||||
ConfigDir string `yaml:"-"`
|
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 ──────────────────────────────────────────────────────────────
|
// ── Identity ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type AgentMeta struct {
|
type AgentMeta struct {
|
||||||
|
|||||||
@@ -209,3 +209,114 @@ skills:
|
|||||||
t.Error("security.sanitize.enabled should be true")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user