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