61606d450d
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.
323 lines
7.9 KiB
Go
323 lines
7.9 KiB
Go
package config
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// TestAgentConfigParseMinimal verifies that a minimal config YAML (with only
|
|
// required fields) parses into AgentConfig without error.
|
|
func TestAgentConfigParseMinimal(t *testing.T) {
|
|
const minimalYAML = `
|
|
agent:
|
|
id: test-bot
|
|
name: Test Bot
|
|
enabled: true
|
|
matrix:
|
|
homeserver: "https://matrix.example.com"
|
|
user_id: "@test:matrix.example.com"
|
|
llm:
|
|
primary:
|
|
provider: openai
|
|
model: gpt-4o
|
|
`
|
|
var cfg AgentConfig
|
|
if err := yaml.Unmarshal([]byte(minimalYAML), &cfg); err != nil {
|
|
t.Fatalf("failed to parse minimal config: %v", err)
|
|
}
|
|
if cfg.Agent.ID != "test-bot" {
|
|
t.Errorf("expected agent.id=test-bot, got %q", cfg.Agent.ID)
|
|
}
|
|
if cfg.Matrix.Homeserver != "https://matrix.example.com" {
|
|
t.Errorf("expected homeserver, got %q", cfg.Matrix.Homeserver)
|
|
}
|
|
if cfg.LLM.Primary.Provider != "openai" {
|
|
t.Errorf("expected provider=openai, got %q", cfg.LLM.Primary.Provider)
|
|
}
|
|
}
|
|
|
|
// TestAgentConfigIgnoresRemovedSections verifies that YAML containing the
|
|
// removed sections (agents, observability, resilience) still parses without
|
|
// error. yaml.v3 silently ignores unknown keys.
|
|
func TestAgentConfigIgnoresRemovedSections(t *testing.T) {
|
|
const yamlWithRemoved = `
|
|
agent:
|
|
id: legacy-bot
|
|
name: Legacy Bot
|
|
enabled: true
|
|
matrix:
|
|
homeserver: "https://matrix.example.com"
|
|
user_id: "@legacy:matrix.example.com"
|
|
llm:
|
|
primary:
|
|
provider: openai
|
|
model: gpt-4o
|
|
|
|
# These sections were removed from the schema but may still exist in old YAMLs.
|
|
agents:
|
|
peers:
|
|
- id: other-bot
|
|
capabilities: [general]
|
|
room: "!abc:server.com"
|
|
delegation:
|
|
enabled: false
|
|
protocol:
|
|
format: json
|
|
channel: matrix
|
|
|
|
observability:
|
|
logging:
|
|
level: info
|
|
format: json
|
|
metrics:
|
|
enabled: false
|
|
health:
|
|
enabled: true
|
|
port: 8080
|
|
tracing:
|
|
enabled: false
|
|
|
|
resilience:
|
|
circuit_breaker:
|
|
failure_threshold: 5
|
|
timeout: 30s
|
|
retry:
|
|
max_attempts: 2
|
|
backoff: exponential
|
|
shutdown:
|
|
timeout: 10s
|
|
queue:
|
|
enabled: true
|
|
max_size: 100
|
|
`
|
|
var cfg AgentConfig
|
|
if err := yaml.Unmarshal([]byte(yamlWithRemoved), &cfg); err != nil {
|
|
t.Fatalf("parsing config with removed sections should succeed, got: %v", err)
|
|
}
|
|
if cfg.Agent.ID != "legacy-bot" {
|
|
t.Errorf("expected agent.id=legacy-bot, got %q", cfg.Agent.ID)
|
|
}
|
|
}
|
|
|
|
// TestAgentConfigParseFull verifies that a config YAML with all active sections
|
|
// parses correctly, including personality with communication.
|
|
func TestAgentConfigParseFull(t *testing.T) {
|
|
const fullYAML = `
|
|
agent:
|
|
id: full-bot
|
|
name: Full Bot
|
|
version: "1.0.0"
|
|
enabled: true
|
|
description: "A fully configured bot"
|
|
tags: [test, full]
|
|
|
|
personality:
|
|
tone: friendly
|
|
verbosity: concise
|
|
language: es
|
|
role: "asistente general"
|
|
communication:
|
|
formality: semiformal
|
|
humor: subtle
|
|
personality: pragmatic
|
|
response_style: structured
|
|
quirks: ["usa analogias"]
|
|
avoid_topics: ["politica"]
|
|
catchphrases: ["interesante"]
|
|
|
|
llm:
|
|
primary:
|
|
provider: openai
|
|
model: gpt-4o
|
|
api_key_env: OPENAI_API_KEY
|
|
max_tokens: 4096
|
|
temperature: 0.7
|
|
tool_use:
|
|
enabled: true
|
|
max_iterations: 5
|
|
|
|
matrix:
|
|
homeserver: "https://matrix.example.com"
|
|
user_id: "@full:matrix.example.com"
|
|
access_token_env: MATRIX_TOKEN
|
|
threads:
|
|
enabled: true
|
|
auto_thread: false
|
|
|
|
tools:
|
|
ssh:
|
|
enabled: false
|
|
http:
|
|
enabled: true
|
|
allowed_domains: ["api.example.com"]
|
|
timeout: 10s
|
|
|
|
security:
|
|
sanitize:
|
|
enabled: true
|
|
mode: warn
|
|
min_severity: medium
|
|
tool_rate_limit:
|
|
enabled: true
|
|
max_calls_per_min: 10
|
|
|
|
storage:
|
|
base_path: "/data/full-bot"
|
|
|
|
memory:
|
|
enabled: true
|
|
window_size: 30
|
|
|
|
skills:
|
|
enabled: true
|
|
path: "skills/"
|
|
categories: ["devops"]
|
|
timeout: 60s
|
|
`
|
|
var cfg AgentConfig
|
|
if err := yaml.Unmarshal([]byte(fullYAML), &cfg); err != nil {
|
|
t.Fatalf("failed to parse full config: %v", err)
|
|
}
|
|
|
|
// Verify key fields
|
|
if cfg.Agent.ID != "full-bot" {
|
|
t.Errorf("agent.id: got %q", cfg.Agent.ID)
|
|
}
|
|
if cfg.Personality.Communication.Humor != "subtle" {
|
|
t.Errorf("personality.communication.humor: got %q", cfg.Personality.Communication.Humor)
|
|
}
|
|
if len(cfg.Personality.Communication.Quirks) != 1 {
|
|
t.Errorf("personality.communication.quirks: expected 1, got %d", len(cfg.Personality.Communication.Quirks))
|
|
}
|
|
if !cfg.LLM.ToolUse.Enabled {
|
|
t.Error("llm.tool_use.enabled should be true")
|
|
}
|
|
if !cfg.Tools.HTTP.Enabled {
|
|
t.Error("tools.http.enabled should be true")
|
|
}
|
|
if cfg.Storage.BasePath != "/data/full-bot" {
|
|
t.Errorf("storage.base_path: got %q", cfg.Storage.BasePath)
|
|
}
|
|
if !cfg.Memory.Enabled {
|
|
t.Error("memory.enabled should be true")
|
|
}
|
|
if !cfg.Skills.Enabled {
|
|
t.Error("skills.enabled should be true")
|
|
}
|
|
if !cfg.Security.Sanitize.Enabled {
|
|
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")
|
|
}
|
|
}
|