diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d1efc58..98f21eb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -56,7 +56,9 @@ shell/mcp/ cliente y servidor MCP (Model Context Protocol) shell/skills/ loader (filesystem) + executor (scripts) shell/effects/ Runner: []Action → side effects shell/bus/ comunicacion inter-agente -agents/runtime.go Agent{}: ensambla core + shell +agents/types.go Runner interface (comun a Agent y Robot) +agents/runtime.go Agent{}: ensambla core + shell (runtime completo con LLM) +agents/robot.go Robot{}: runtime ligero command-only (sin LLM, reglas, memoria) agents// agent.go (reglas puras) + config.yaml + prompts/system.md tools/ tool registry + tool implementations (subpackages) tools/mcptools/ bridge: convierte MCP tools → tools.Tool @@ -97,18 +99,22 @@ Guias detalladas en `.claude/rules/index.md`: | Regla | Cuando | |-------|--------| -| `create_agent.md` | Crear nuevo bot/agente | +| `create_agent.md` | Crear nuevo bot/agente/robot | | `create_tool.md` | Añadir tool para function calling | | `create_command.md` | Añadir comando !xxx | | `create_issue.md` | Crear issue en dev/issues/ | | `fix_issue.md` | Implementar un issue existente | -## Agentes +## Agentes y Robots -| ID | LLM | Descripcion | -|----|-----|-------------| -| assistant-bot | GPT-4o | Asistente general, DMs | -| asistente-2 | GPT-4o | Asistente con tools | +Dos tipos de runtime: **Agent** (completo, con LLM) y **Robot** (ligero, solo comandos). +Config: `agent.type: "agent"` (default) o `agent.type: "robot"`. +Templates: `agents/_template/` (agent) y `agents/_template_robot/` (robot). + +| ID | Tipo | LLM | Descripcion | +|----|------|-----|-------------| +| assistant-bot | agent | GPT-4o | Asistente general, DMs | +| asistente-2 | agent | GPT-4o | Asistente con tools | ## Build diff --git a/.claude/rules/create_agent.md b/.claude/rules/create_agent.md index a2864bb..6c713e0 100644 --- a/.claude/rules/create_agent.md +++ b/.claude/rules/create_agent.md @@ -1,20 +1,40 @@ -# Policy: Crear un nuevo agente +# Policy: Crear un nuevo agente o robot -Guía ejecutable para Claude. Seguir paso a paso sin desviarse. +Guia ejecutable para Claude. Seguir paso a paso sin desviarse. + +## Robot vs Agent — decidir primero + +| | Agent | Robot | +|---|---|---| +| **Cuando usar** | Necesita LLM, reglas, memoria, tools | Solo responde comandos (!xxx) | +| **Runtime** | `agents.New()` — completo | `agents.NewRobot()` — ligero | +| **Config type** | `type: agent` (default) | `type: robot` | +| **LLM** | Si | No | +| **Reglas** | Si (`agent.go` con `Rules()`) | No (sin `agent.go`) | +| **Memoria/Knowledge/Skills** | Si (opcionales) | No | +| **Tools** | Si (opcionales) | No | +| **System prompt** | Si (`prompts/system.md`) | No necesario | +| **Comandos built-in** | help, ping, tools, tool, status, info, clear, prompts, version | help, ping, status, info, version | +| **Comandos custom** | Si (`RegisterCommand`) | Si (`RegisterCommand`) | +| **Template** | `agents/_template/` | `agents/_template_robot/` | +| **Config ejemplo** | ~260 lineas | ~55 lineas | + +**Regla**: si el bot necesita entender lenguaje natural, es un Agent. Si solo necesita comandos directos, es un Robot. ## Inputs — preguntar al usuario si no los da | Input | Requerido | Default | Ejemplo | |-------|-----------|---------|---------| -| `agent-id` | sí | — | `monitor-bot` | -| `display-name` | sí | — | `"Monitor Agent"` | -| `description` | sí | — | `"Monitorea servicios y reporta estado"` | -| `llm.provider` | no | `openai` | `openai` o `anthropic` | -| `llm.model` | no | `gpt-4o` | `gpt-4o`, `claude-sonnet-4-20250514` | -| `tool_use` | no | `false` | `true` si necesita herramientas | -| System prompt | sí | — | Texto describiendo rol y capacidades | +| `agent-id` | si | — | `monitor-bot` | +| `display-name` | si | — | `"Monitor Agent"` | +| `description` | si | — | `"Monitorea servicios y reporta estado"` | +| `type` | no | `agent` | `agent` o `robot` | +| `llm.provider` | no (N/A para robots) | `openai` | `openai` o `anthropic` | +| `llm.model` | no (N/A para robots) | `gpt-4o` | `gpt-4o`, `claude-sonnet-4-20250514` | +| `tool_use` | no (N/A para robots) | `false` | `true` si necesita herramientas | +| System prompt | si (N/A para robots) | — | Texto describiendo rol y capacidades | -Si el usuario da todos los inputs, ir directo a la Ruta Rápida. Si faltan, preguntar antes de empezar. +Si el usuario da todos los inputs, ir directo a la Ruta Rapida. Si faltan, preguntar antes de empezar. ## Ruta rápida — script automatizado diff --git a/agents/_template_robot/config.yaml b/agents/_template_robot/config.yaml new file mode 100644 index 0000000..ded4a43 --- /dev/null +++ b/agents/_template_robot/config.yaml @@ -0,0 +1,57 @@ +# ============================================ +# ROBOT PLANTILLA (command-only, sin LLM) +# ============================================ +# Referencia canonica para robots. NO se lanza (template: true). +# Un robot solo responde a comandos (!xxx). Mensajes normales se ignoran. +# Copiar y adaptar para nuevos robots. + +agent: + id: "_template_robot" + name: "Template Robot" + version: "0.0.0" + type: robot # robot = command-only, sin LLM ni reglas + enabled: true + template: true # el launcher ignora este robot + description: "Robot plantilla. No se lanza." + tags: [template, robot] + +# ============================================ +# PERSONALIDAD (minima para robots) +# ============================================ +personality: + prefix: "" + language: es + +# ============================================ +# MATRIX +# ============================================ +matrix: + homeserver: "https://matrix.example.com" + user_id: "@robot:matrix.example.com" + access_token_env: MATRIX_TOKEN_ROBOT + device_id: "DEVICEID" + + encryption: + enabled: false + store_path: "./agents/_template_robot/data/crypto/" + pickle_key_env: PICKLE_KEY_ROBOT + trust_mode: tofu + recovery_key_env: "" + + rooms: + listen: [] + respond: [] + admin: [] + + filters: + command_prefix: "!" + mention_respond: false # robots no responden a menciones (no hay LLM) + dm_respond: false # robots no responden a DMs (no hay LLM) + ignore_bots: true + ignore_users: [] + unauthorized_response: silent + min_power_level: 0 + + threads: + enabled: true + auto_thread: false diff --git a/agents/robot.go b/agents/robot.go new file mode 100644 index 0000000..b2de743 --- /dev/null +++ b/agents/robot.go @@ -0,0 +1,294 @@ +package agents + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "maunium.net/go/mautrix/event" + + "github.com/enmanuel/agents/internal/config" + "github.com/enmanuel/agents/pkg/command" + "github.com/enmanuel/agents/pkg/decision" + "github.com/enmanuel/agents/shell/matrix" +) + +// Robot is a lightweight runtime for command-only bots. +// Unlike Agent, it has no LLM, rules, memory, knowledge, skills, or tools. +// It connects to Matrix and dispatches commands; non-command messages are ignored. +type Robot struct { + cfg *config.AgentConfig + matrix *matrix.Client + logger *slog.Logger + + // E2EE crypto store — non-nil when encryption is enabled; closed on shutdown. + cryptoStore io.Closer + + // Lifecycle + cancel context.CancelFunc + done chan struct{} + + // Commands — handlers keyed by canonical name; aliases maps alias → canonical. + commands map[string]CommandHandler + cmdAliases map[string]string + customSpecs []command.Spec + startTime time.Time + + // Personality prefix for replies + prefix string + + // Matrix listener + listener *matrix.Listener +} + +// NewRobot creates a lightweight command-only bot from its config and logger. +// It initializes only the Matrix client, E2EE (if configured), and built-in commands. +func NewRobot(cfg *config.AgentConfig, logger *slog.Logger) (*Robot, error) { + matrixClient, err := matrix.New(cfg.Matrix) + if err != nil { + return nil, fmt.Errorf("matrix client: %w", err) + } + + // E2EE — initialize before the sync loop starts + var cryptoStore io.Closer + if cfg.Matrix.Encryption.Enabled { + storePath := filepath.Join(cfg.Matrix.Encryption.StorePath, "crypto.db") + pickleKey := os.Getenv(cfg.Matrix.Encryption.PickleKeyEnv) + logger.Info("initializing e2ee", "store", storePath) + cryptoStore, err = matrixClient.InitCrypto(context.Background(), storePath, pickleKey, cfg.Agent.ID) + if err != nil { + return nil, fmt.Errorf("e2ee init: %w", err) + } + + // Auto-fetch cross-signing private keys from SSSS if recovery key is configured. + if envName := cfg.Matrix.Encryption.RecoveryKeyEnv; envName != "" { + if rk := os.Getenv(envName); rk != "" { + if err := matrixClient.FetchCrossSigningKeys(context.Background(), rk); err != nil { + logger.Warn("failed to fetch cross-signing keys from SSSS (non-fatal)", "err", err) + } else { + logger.Info("cross-signing private keys fetched from SSSS") + } + } + } + + // Sign own device with the self-signing key so Element shows it as verified. + if err := matrixClient.SignOwnDevice(context.Background()); err != nil { + logger.Warn("failed to sign own device (non-fatal)", "err", err) + } else { + logger.Info("own device signed with cross-signing key") + } + + logger.Info("e2ee ready") + } + + r := &Robot{ + cfg: cfg, + matrix: matrixClient, + logger: logger, + cryptoStore: cryptoStore, + done: make(chan struct{}), + commands: make(map[string]CommandHandler), + cmdAliases: command.BuiltinNames(), + startTime: time.Now(), + prefix: cfg.Personality.Prefix, + } + + // Register built-in commands (robot-appropriate subset). + r.registerBuiltinCommands() + + // Matrix event listener + r.listener = matrix.NewListener(matrixClient, cfg.Matrix, r.handleEvent, logger) + + return r, nil +} + +// registerBuiltinCommands registers command handlers appropriate for a robot. +// Robots support: help, ping, status, info, version. +// They do NOT support: tools, tool, clear, prompts (no LLM, no memory, no tools). +func (r *Robot) registerBuiltinCommands() { + r.commands["help"] = r.cmdHelp + r.commands["ping"] = r.cmdPing + r.commands["status"] = r.cmdStatus + r.commands["info"] = r.cmdInfo + r.commands["version"] = r.cmdVersion +} + +// RegisterCommand adds a custom command handler for this robot. +func (r *Robot) RegisterCommand(spec command.Spec, handler CommandHandler) { + r.commands[spec.Name] = handler + r.cmdAliases[spec.Name] = spec.Name + for _, alias := range spec.Aliases { + r.cmdAliases[alias] = spec.Name + } + r.customSpecs = append(r.customSpecs, spec) + r.logger.Info("command_registered", "command", spec.Name, "aliases", spec.Aliases) +} + +// Run starts the robot sync loop. Blocks until ctx is cancelled. +func (r *Robot) Run(ctx context.Context) error { + ctx, r.cancel = context.WithCancel(ctx) + defer close(r.done) + + if r.cryptoStore != nil { + defer r.cryptoStore.Close() + } + + r.logger.Info("robot starting", + "id", r.cfg.Agent.ID, + "name", r.cfg.Agent.Name, + "type", "robot", + ) + + // Set presence to online + if err := r.matrix.SetPresence(ctx, event.PresenceOnline); err != nil { + r.logger.Warn("failed to set presence online", "err", err) + } + defer func() { + offlineCtx := context.Background() + if err := r.matrix.SetPresence(offlineCtx, event.PresenceOffline); err != nil { + r.logger.Warn("failed to set presence offline", "err", err) + } + }() + + return r.listener.Run(ctx) +} + +// Stop cancels this robot's individual context, causing Run to return. +func (r *Robot) Stop() { + if r.cancel != nil { + r.cancel() + } +} + +// Done returns a channel that is closed when Run has returned. +func (r *Robot) Done() <-chan struct{} { + return r.done +} + +// handleEvent is called by the matrix Listener for each filtered incoming event. +// For a robot, only commands are processed; all other messages are silently ignored. +func (r *Robot) handleEvent(ctx context.Context, msgCtx decision.MessageContext, evt *event.Event) { + roomID := evt.RoomID.String() + + // Only process commands. Non-command messages are silently ignored. + if msgCtx.Command == "" { + r.logger.Debug("non-command message, ignoring (robot)", + "sender", msgCtx.SenderID, + "room", roomID, + ) + return + } + + r.logger.Info("command_received", + "command", msgCtx.Command, + "sender", msgCtx.SenderID, + "room", roomID, + "args", msgCtx.Args, + ) + + // Resolve aliases + cmdName := msgCtx.Command + if canonical, ok := r.cmdAliases[cmdName]; ok { + cmdName = canonical + } + + if handler, ok := r.commands[cmdName]; ok { + r.logger.Info("command_executed", "command", cmdName) + reply := handler(ctx, msgCtx) + _ = r.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID, reply) + return + } + + // Unknown command + r.logger.Info("command_unknown", "command", msgCtx.Command) + _ = r.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID, + fmt.Sprintf("Comando desconocido: `!%s`. Usa `!help` para ver comandos disponibles.", msgCtx.Command)) +} + +// sendReply sends a markdown reply that respects thread context. +func (r *Robot) sendReply(ctx context.Context, roomID, eventID, threadID, markdown string) error { + if threadID != "" { + return r.matrix.SendThreadMarkdown(ctx, roomID, threadID, eventID, markdown) + } + return r.matrix.SendReplyMarkdown(ctx, roomID, eventID, markdown) +} + +// ── Built-in command handlers (robot subset) ───────────────────────────── + +func (r *Robot) cmdHelp(_ context.Context, _ decision.MessageContext) string { + var b strings.Builder + b.WriteString("**Comandos disponibles:**\n\n") + + // Built-in commands appropriate for robots + robotBuiltins := []command.Spec{ + {Name: "help", Aliases: []string{"h"}, Description: "Lista comandos disponibles", Usage: "!help"}, + {Name: "ping", Description: "Alive check", Usage: "!ping"}, + {Name: "status", Description: "Info del robot: uptime", Usage: "!status"}, + {Name: "info", Description: "Nombre, version y descripcion", Usage: "!info"}, + {Name: "version", Aliases: []string{"v"}, Description: "Version del robot", Usage: "!version"}, + } + for _, spec := range robotBuiltins { + writeSpec(&b, spec) + } + + // Agent-specific commands (registered via RegisterCommand) + if len(r.customSpecs) > 0 { + b.WriteString("\n**Comandos del robot:**\n\n") + for _, spec := range r.customSpecs { + if spec.Hidden { + continue + } + writeSpec(&b, spec) + } + } + + return b.String() +} + +func (r *Robot) cmdPing(_ context.Context, _ decision.MessageContext) string { + return fmt.Sprintf("pong — %s", time.Now().Format(time.RFC3339)) +} + +func (r *Robot) cmdStatus(_ context.Context, _ decision.MessageContext) string { + uptime := time.Since(r.startTime).Truncate(time.Second) + + var b strings.Builder + fmt.Fprintf(&b, "**Estado de %s:**\n\n", r.cfg.Agent.Name) + fmt.Fprintf(&b, "- **Tipo:** robot\n") + fmt.Fprintf(&b, "- **Uptime:** %s\n", uptime) + fmt.Fprintf(&b, "- **Comandos custom:** %d\n", len(r.customSpecs)) + + return b.String() +} + +func (r *Robot) cmdInfo(_ context.Context, _ decision.MessageContext) string { + var b strings.Builder + + b.WriteString("## Identidad\n\n") + fmt.Fprintf(&b, "- **Nombre:** %s\n", r.cfg.Agent.Name) + fmt.Fprintf(&b, "- **ID:** `%s`\n", r.cfg.Agent.ID) + fmt.Fprintf(&b, "- **Tipo:** robot\n") + if r.cfg.Agent.Version != "" { + fmt.Fprintf(&b, "- **Version:** %s\n", r.cfg.Agent.Version) + } + fmt.Fprintf(&b, "- **Descripcion:** %s\n", r.cfg.Agent.Description) + + uptime := time.Since(r.startTime).Round(time.Second) + b.WriteString("\n## Uptime\n\n") + fmt.Fprintf(&b, "- **Activo desde:** %s\n", uptime) + + return b.String() +} + +func (r *Robot) cmdVersion(_ context.Context, _ decision.MessageContext) string { + v := r.cfg.Agent.Version + if v == "" { + v = "sin version" + } + return fmt.Sprintf("%s %s", r.cfg.Agent.Name, v) +} diff --git a/agents/robot_test.go b/agents/robot_test.go new file mode 100644 index 0000000..0076347 --- /dev/null +++ b/agents/robot_test.go @@ -0,0 +1,290 @@ +package agents + +import ( + "context" + "log/slog" + "os" + "strings" + "testing" + "time" + + "github.com/enmanuel/agents/internal/config" + "github.com/enmanuel/agents/pkg/command" + "github.com/enmanuel/agents/pkg/decision" +) + +// newTestRobot creates a minimal Robot for testing without requiring +// Matrix or network. Fields are initialized directly. +func newTestRobot(t *testing.T) *Robot { + t.Helper() + cfg := &config.AgentConfig{ + Agent: config.AgentMeta{ + ID: "test-robot", + Name: "Test Robot", + Type: "robot", + Description: "robot for tests", + Version: "1.0.0", + }, + } + r := &Robot{ + cfg: cfg, + logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), + done: make(chan struct{}), + commands: make(map[string]CommandHandler), + cmdAliases: command.BuiltinNames(), + startTime: time.Now(), + } + r.registerBuiltinCommands() + return r +} + +// TestRobotCmdHelp verifies !help lists built-in commands. +func TestRobotCmdHelp(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdHelp(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "Comandos disponibles") { + t.Error("help reply missing header") + } + for _, cmd := range []string{"help", "ping", "status", "info", "version"} { + if !strings.Contains(reply, "!"+cmd) { + t.Errorf("help reply missing command !%s", cmd) + } + } + // Robot should NOT show agent-only commands + for _, cmd := range []string{"!tools", "!tool", "!clear", "!prompts"} { + if strings.Contains(reply, cmd+"`") { + t.Errorf("help reply should not contain agent-only command %s", cmd) + } + } +} + +// TestRobotCmdHelpWithCustom verifies !help includes custom commands. +func TestRobotCmdHelpWithCustom(t *testing.T) { + r := newTestRobot(t) + + r.RegisterCommand( + command.Spec{Name: "deploy", Description: "Deploy to env", Usage: "!deploy "}, + func(_ context.Context, _ decision.MessageContext) string { return "deployed" }, + ) + + reply := r.cmdHelp(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "Comandos del robot") { + t.Error("help reply missing 'Comandos del robot' section") + } + if !strings.Contains(reply, "!deploy") { + t.Error("help reply missing custom command !deploy") + } +} + +// TestRobotCmdPing verifies !ping returns pong. +func TestRobotCmdPing(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdPing(context.Background(), decision.MessageContext{}) + + if !strings.HasPrefix(reply, "pong") { + t.Errorf("ping reply should start with 'pong', got %q", reply) + } +} + +// TestRobotCmdStatus verifies !status includes type and uptime. +func TestRobotCmdStatus(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdStatus(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "robot") { + t.Error("status reply missing type 'robot'") + } + if !strings.Contains(reply, "Uptime") { + t.Error("status reply missing Uptime") + } +} + +// TestRobotCmdInfo verifies !info shows robot identity. +func TestRobotCmdInfo(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdInfo(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "Test Robot") { + t.Error("info reply missing robot name") + } + if !strings.Contains(reply, "test-robot") { + t.Error("info reply missing robot ID") + } + if !strings.Contains(reply, "robot") { + t.Error("info reply missing type 'robot'") + } +} + +// TestRobotCmdVersion verifies !version returns name + version. +func TestRobotCmdVersion(t *testing.T) { + r := newTestRobot(t) + + reply := r.cmdVersion(context.Background(), decision.MessageContext{}) + + if reply != "Test Robot 1.0.0" { + t.Errorf("version reply = %q, want %q", reply, "Test Robot 1.0.0") + } +} + +// TestRobotIgnoresNonCommand verifies that handleEvent silently ignores +// non-command messages (no error, no reply). +func TestRobotIgnoresNonCommand(t *testing.T) { + r := newTestRobot(t) + + // handleEvent with empty Command should not panic. + // Since we can't easily mock the Matrix client, we verify the method + // returns without error by checking it doesn't reach command dispatch. + msgCtx := decision.MessageContext{ + Command: "", // non-command + Content: "hola bot", + } + + // The robot should just return without doing anything. + // We can't call handleEvent directly because it needs an *event.Event, + // but we can verify the logic by checking the command map behavior. + if _, ok := r.commands[""]; ok { + t.Error("empty string should not be a registered command") + } + + // Verify no commands match empty string. + if _, ok := r.cmdAliases[""]; ok { + t.Error("empty string should not be in aliases") + } + + _ = msgCtx // used to document test intent +} + +// TestRobotCustomCommand verifies RegisterCommand works and the handler executes. +func TestRobotCustomCommand(t *testing.T) { + r := newTestRobot(t) + + executed := false + r.RegisterCommand( + command.Spec{ + Name: "deploy", + Aliases: []string{"d"}, + Description: "Deploy to env", + Usage: "!deploy ", + }, + func(_ context.Context, msgCtx decision.MessageContext) string { + executed = true + if len(msgCtx.Args) == 0 { + return "Uso: !deploy " + } + return "Deploying to " + msgCtx.Args[0] + }, + ) + + // Verify command is registered + handler, ok := r.commands["deploy"] + if !ok { + t.Fatal("deploy command not registered") + } + + // Execute the handler + reply := handler(context.Background(), decision.MessageContext{ + Command: "deploy", + Args: []string{"staging"}, + }) + + if !executed { + t.Error("handler was not executed") + } + if reply != "Deploying to staging" { + t.Errorf("reply = %q, want %q", reply, "Deploying to staging") + } + + // Verify alias works + canonical, ok := r.cmdAliases["d"] + if !ok { + t.Fatal("alias 'd' not registered") + } + if canonical != "deploy" { + t.Errorf("alias canonical = %q, want %q", canonical, "deploy") + } + + // Verify custom spec is tracked (for !help) + if len(r.customSpecs) != 1 { + t.Fatalf("customSpecs len = %d, want 1", len(r.customSpecs)) + } + if r.customSpecs[0].Name != "deploy" { + t.Errorf("customSpecs[0].Name = %q, want %q", r.customSpecs[0].Name, "deploy") + } +} + +// TestRobotStopAndDone verifies lifecycle methods work correctly. +func TestRobotStopAndDone(t *testing.T) { + r := &Robot{ + done: make(chan struct{}), + } + + ctx, cancel := context.WithCancel(context.Background()) + r.cancel = cancel + + started := make(chan struct{}) + go func() { + close(started) + <-ctx.Done() + close(r.done) + }() + + <-started + + r.Stop() + + select { + case <-r.Done(): + // ok + case <-time.After(2 * time.Second): + t.Fatal("Done() did not close within 2s after Stop()") + } +} + +// TestRobotStopNilCancel verifies Stop is safe when cancel is nil. +func TestRobotStopNilCancel(t *testing.T) { + r := &Robot{ + done: make(chan struct{}), + } + // cancel is nil — must not panic. + r.Stop() +} + +// TestRunnerInterfaceSatisfied verifies that both Agent and Robot +// satisfy the Runner interface at compile time. +func TestRunnerInterfaceSatisfied(t *testing.T) { + // These are compile-time checks — if they compile, the test passes. + var _ Runner = (*Agent)(nil) + var _ Runner = (*Robot)(nil) +} + +// TestRobotBuiltinCommandCount verifies the robot has exactly the expected +// built-in commands and not more. +func TestRobotBuiltinCommandCount(t *testing.T) { + r := newTestRobot(t) + + expected := map[string]bool{ + "help": true, + "ping": true, + "status": true, + "info": true, + "version": true, + } + + for name := range r.commands { + if !expected[name] { + t.Errorf("unexpected built-in command %q in robot", name) + } + } + + for name := range expected { + if _, ok := r.commands[name]; !ok { + t.Errorf("missing built-in command %q in robot", name) + } + } +} diff --git a/agents/types.go b/agents/types.go new file mode 100644 index 0000000..84dd4fd --- /dev/null +++ b/agents/types.go @@ -0,0 +1,20 @@ +package agents + +import ( + "context" + + "github.com/enmanuel/agents/pkg/command" +) + +// Runner is the common interface that both Agent and Robot satisfy. +// The launcher uses this to manage agents and robots uniformly. +type Runner interface { + // Run starts the Matrix sync loop. Blocks until ctx is cancelled. + Run(ctx context.Context) error + // Stop cancels the runner's internal context, causing Run to return. + Stop() + // Done returns a channel closed when Run has returned. + Done() <-chan struct{} + // RegisterCommand adds a custom command handler. + RegisterCommand(spec command.Spec, handler CommandHandler) +} diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go index f080f17..a5c7d5c 100644 --- a/cmd/launcher/main.go +++ b/cmd/launcher/main.go @@ -158,8 +158,6 @@ func main() { continue } - rules := rulesFor(cfg.Agent.ID, logger) - // Per-agent logger → writes to logs//YYYY-MM-DD.jsonl agentLogger, agentCleanup, aErr := agentlog.NewAgentLogger(agentlog.LoggerConfig{ BaseDir: logDir, @@ -172,41 +170,59 @@ func main() { agentCleanup = func() {} } - // Resolve centralized ACL for this agent - agentACL := pksecurity.ResolveACL(cfg.Agent.ID, deps.secPolicy) - agentLogger.Debug("resolved acl for agent", - "agent", cfg.Agent.ID, - "acl_empty", agentACL.Empty(), - ) + // Branch: robot (command-only, lightweight) vs agent (full runtime). + var runner agents.Runner - a, err := agents.New(cfg, rules, agentACL, agentLogger) - if err != nil { - logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", err) - agentCleanup() - continue - } + if cfg.Agent.Type == "robot" { + robot, rErr := agents.NewRobot(cfg, agentLogger) + if rErr != nil { + logger.Error("failed to create robot", "id", cfg.Agent.ID, "err", rErr) + agentCleanup() + continue + } + runner = robot + agentLogger.Info("created robot", "id", cfg.Agent.ID) + } else { + rules := rulesFor(cfg.Agent.ID, logger) - // Connect agent to bus for orchestration - a.SetBus(agentBus) + // Resolve centralized ACL for this agent + agentACL := pksecurity.ResolveACL(cfg.Agent.ID, deps.secPolicy) + agentLogger.Debug("resolved acl for agent", + "agent", cfg.Agent.ID, + "acl_empty", agentACL.Empty(), + ) - // If orchestrator is active, wire interceptor and membership notify - if orch != nil { - a.SetInterceptor(orch.orchestrator.Intercept) - a.SetMembershipNotify(orch.orchestrator.NotifyMembership) + a, cErr := agents.New(cfg, rules, agentACL, agentLogger) + if cErr != nil { + logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", cErr) + agentCleanup() + continue + } - orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{ - ID: cfg.Agent.ID, - MatrixUserID: cfg.Matrix.UserID, - Description: cfg.Agent.Description, - Capabilities: cfg.Agent.Tags, - }) + // Connect agent to bus for orchestration + a.SetBus(agentBus) - // Grab the first available Matrix client for room scanning - scannerOnce.set(a.RawMatrixClient()) + // If orchestrator is active, wire interceptor and membership notify + if orch != nil { + a.SetInterceptor(orch.orchestrator.Intercept) + a.SetMembershipNotify(orch.orchestrator.NotifyMembership) + + orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{ + ID: cfg.Agent.ID, + MatrixUserID: cfg.Matrix.UserID, + Description: cfg.Agent.Description, + Capabilities: cfg.Agent.Tags, + }) + + // Grab the first available Matrix client for room scanning + scannerOnce.set(a.RawMatrixClient()) + } + + runner = a } registry.register(&runningAgent{ - agent: a, + runner: runner, cfg: cfg, cfgPath: path, logger: agentLogger, diff --git a/cmd/launcher/registry.go b/cmd/launcher/registry.go index 943b6de..ce23d14 100644 --- a/cmd/launcher/registry.go +++ b/cmd/launcher/registry.go @@ -17,9 +17,9 @@ import ( agentlog "github.com/enmanuel/agents/shell/logger" ) -// runningAgent holds a live agent and the metadata needed to recreate it. +// runningAgent holds a live runner (Agent or Robot) and the metadata needed to recreate it. type runningAgent struct { - agent *agents.Agent + runner agents.Runner cfg *config.AgentConfig cfgPath string logger *slog.Logger @@ -50,21 +50,26 @@ func newAgentRegistry(deps *launchDeps) *agentRegistry { } } -// register adds a running agent to the registry and starts its goroutine. +// register adds a running agent/robot to the registry and starts its goroutine. func (r *agentRegistry) register(ra *runningAgent) { r.mu.Lock() r.agents[ra.cfg.Agent.ID] = ra r.mu.Unlock() + runtimeType := ra.cfg.Agent.Type + if runtimeType == "" { + runtimeType = "agent" + } + go func() { - ra.logger.Info("agent running") - if err := ra.agent.Run(r.deps.parentCtx); err != nil { - ra.logger.Error("agent stopped with error", "err", err) + ra.logger.Info("runner started", "type", runtimeType) + if err := ra.runner.Run(r.deps.parentCtx); err != nil { + ra.logger.Error("runner stopped with error", "err", err, "type", runtimeType) } }() } -// stopAndWait stops a running agent and waits for it to finish. +// stopAndWait stops a running agent/robot and waits for it to finish. // Caller must NOT hold r.mu. func (r *agentRegistry) stopAndWait(id string) { r.mu.Lock() @@ -74,11 +79,11 @@ func (r *agentRegistry) stopAndWait(id string) { return } - ra.agent.Stop() + ra.runner.Stop() select { - case <-ra.agent.Done(): + case <-ra.runner.Done(): case <-time.After(10 * time.Second): - ra.logger.Warn("agent did not stop within 10s, forcing", "id", id) + ra.logger.Warn("runner did not stop within 10s, forcing", "id", id) } // Unsubscribe from bus so no stale channel remains. @@ -133,32 +138,45 @@ func (r *agentRegistry) reload(id string, rulesFor func(string, *slog.Logger) [] newCleanup = func() {} } - // 5. Create new agent (validates config before discarding the old one). - rules := rulesFor(cfg.Agent.ID, newLogger) - agentACL := pksecurity.ResolveACL(cfg.Agent.ID, r.deps.secPolicy) - newLogger.Debug("resolved acl for agent (reload)", "agent", cfg.Agent.ID, "acl_empty", agentACL.Empty()) - newAgent, err := agents.New(cfg, rules, agentACL, newLogger) - if err != nil { - newLogger.Error("reload: failed to create agent", "id", id, "err", err) - newCleanup() - return - } + // 5. Create new runner (validates config before discarding the old one). + var newRunner agents.Runner - // 6. Wire bus and orchestration. - newAgent.SetBus(r.deps.agentBus) - if r.deps.orch != nil { - newAgent.SetInterceptor(r.deps.orch.orchestrator.Intercept) - newAgent.SetMembershipNotify(r.deps.orch.orchestrator.NotifyMembership) - r.deps.orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{ - ID: cfg.Agent.ID, - MatrixUserID: cfg.Matrix.UserID, - Description: cfg.Agent.Description, - Capabilities: cfg.Agent.Tags, - }) + if cfg.Agent.Type == "robot" { + robot, rErr := agents.NewRobot(cfg, newLogger) + if rErr != nil { + newLogger.Error("reload: failed to create robot", "id", id, "err", rErr) + newCleanup() + return + } + newRunner = robot + } else { + rules := rulesFor(cfg.Agent.ID, newLogger) + agentACL := pksecurity.ResolveACL(cfg.Agent.ID, r.deps.secPolicy) + newLogger.Debug("resolved acl for agent (reload)", "agent", cfg.Agent.ID, "acl_empty", agentACL.Empty()) + newAgent, aErr := agents.New(cfg, rules, agentACL, newLogger) + if aErr != nil { + newLogger.Error("reload: failed to create agent", "id", id, "err", aErr) + newCleanup() + return + } + + // Wire bus and orchestration (only for agents, not robots). + newAgent.SetBus(r.deps.agentBus) + if r.deps.orch != nil { + newAgent.SetInterceptor(r.deps.orch.orchestrator.Intercept) + newAgent.SetMembershipNotify(r.deps.orch.orchestrator.NotifyMembership) + r.deps.orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{ + ID: cfg.Agent.ID, + MatrixUserID: cfg.Matrix.UserID, + Description: cfg.Agent.Description, + Capabilities: cfg.Agent.Tags, + }) + } + newRunner = newAgent } newRA := &runningAgent{ - agent: newAgent, + runner: newRunner, cfg: cfg, cfgPath: cfgPath, logger: newLogger, @@ -170,14 +188,18 @@ func (r *agentRegistry) reload(id string, rulesFor func(string, *slog.Logger) [] r.mu.Unlock() // 7. Start new goroutine. + runtimeType := cfg.Agent.Type + if runtimeType == "" { + runtimeType = "agent" + } go func() { - newLogger.Info("agent running") - if err := newAgent.Run(r.deps.parentCtx); err != nil { - newLogger.Error("agent stopped with error", "err", err) + newLogger.Info("runner started", "type", runtimeType) + if err := newRunner.Run(r.deps.parentCtx); err != nil { + newLogger.Error("runner stopped with error", "err", err, "type", runtimeType) } }() - newLogger.Info("agent_reloaded", "id", id) + newLogger.Info("runner_reloaded", "id", id, "type", runtimeType) } // reloadAll reloads every registered agent sequentially. @@ -194,12 +216,12 @@ func (r *agentRegistry) reloadAll(rulesFor func(string, *slog.Logger) []decision } } -// waitAll blocks until all registered agents have stopped. +// waitAll blocks until all registered runners have stopped. func (r *agentRegistry) waitAll() { r.mu.Lock() dones := make([]<-chan struct{}, 0, len(r.agents)) for _, ra := range r.agents { - dones = append(dones, ra.agent.Done()) + dones = append(dones, ra.runner.Done()) } r.mu.Unlock() diff --git a/dev/issues/README.md b/dev/issues/README.md index 6fe9bbc..f0a9fee 100644 --- a/dev/issues/README.md +++ b/dev/issues/README.md @@ -40,6 +40,6 @@ afectados y notas de implementacion. | 27 | Limpiar config schema | [0027-prune-config-schema.md](completed/0027-prune-config-schema.md) | completado | | 28 | Desacoplar launcher | [0028-decouple-launcher.md](completed/0028-decouple-launcher.md) | completado | | 29 | Tests para runtime y config | [0029-core-tests.md](0029-core-tests.md) | pendiente | -| 30 | Separacion Robot vs Agente | [0030-robot-vs-agent.md](0030-robot-vs-agent.md) | pendiente | +| 30 | Separacion Robot vs Agente | [0030-robot-vs-agent.md](completed/0030-robot-vs-agent.md) | completado | | 31 | Expandir tools/file/ | [0031-expand-file-tools.md](completed/0031-expand-file-tools.md) | completado | | 32 | E2E: verificar skill /create-agent | [0032-e2e-create-agent-skill.md](0032-e2e-create-agent-skill.md) | pendiente | diff --git a/dev/issues/0030-robot-vs-agent.md b/dev/issues/completed/0030-robot-vs-agent.md similarity index 100% rename from dev/issues/0030-robot-vs-agent.md rename to dev/issues/completed/0030-robot-vs-agent.md diff --git a/internal/config/schema.go b/internal/config/schema.go index 2e28a1f..8a0838a 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -24,6 +24,7 @@ type AgentMeta struct { ID string `yaml:"id"` Name string `yaml:"name"` Version string `yaml:"version"` + Type string `yaml:"type"` // "agent" (default) or "robot" (command-only, no LLM) Enabled bool `yaml:"enabled"` Template bool `yaml:"template"` // if true, launcher will skip this agent Description string `yaml:"description"`