merge: issue/0030-robot-vs-agent — tipo Robot ligero para bots de comandos
This commit is contained in:
+13
-7
@@ -56,7 +56,9 @@ shell/mcp/ cliente y servidor MCP (Model Context Protocol)
|
|||||||
shell/skills/ loader (filesystem) + executor (scripts)
|
shell/skills/ loader (filesystem) + executor (scripts)
|
||||||
shell/effects/ Runner: []Action → side effects
|
shell/effects/ Runner: []Action → side effects
|
||||||
shell/bus/ comunicacion inter-agente
|
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/<id>/ agent.go (reglas puras) + config.yaml + prompts/system.md
|
agents/<id>/ agent.go (reglas puras) + config.yaml + prompts/system.md
|
||||||
tools/ tool registry + tool implementations (subpackages)
|
tools/ tool registry + tool implementations (subpackages)
|
||||||
tools/mcptools/ bridge: convierte MCP tools → tools.Tool
|
tools/mcptools/ bridge: convierte MCP tools → tools.Tool
|
||||||
@@ -97,18 +99,22 @@ Guias detalladas en `.claude/rules/index.md`:
|
|||||||
|
|
||||||
| Regla | Cuando |
|
| 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_tool.md` | Añadir tool para function calling |
|
||||||
| `create_command.md` | Añadir comando !xxx |
|
| `create_command.md` | Añadir comando !xxx |
|
||||||
| `create_issue.md` | Crear issue en dev/issues/ |
|
| `create_issue.md` | Crear issue en dev/issues/ |
|
||||||
| `fix_issue.md` | Implementar un issue existente |
|
| `fix_issue.md` | Implementar un issue existente |
|
||||||
|
|
||||||
## Agentes
|
## Agentes y Robots
|
||||||
|
|
||||||
| ID | LLM | Descripcion |
|
Dos tipos de runtime: **Agent** (completo, con LLM) y **Robot** (ligero, solo comandos).
|
||||||
|----|-----|-------------|
|
Config: `agent.type: "agent"` (default) o `agent.type: "robot"`.
|
||||||
| assistant-bot | GPT-4o | Asistente general, DMs |
|
Templates: `agents/_template/` (agent) y `agents/_template_robot/` (robot).
|
||||||
| asistente-2 | GPT-4o | Asistente con tools |
|
|
||||||
|
| ID | Tipo | LLM | Descripcion |
|
||||||
|
|----|------|-----|-------------|
|
||||||
|
| assistant-bot | agent | GPT-4o | Asistente general, DMs |
|
||||||
|
| asistente-2 | agent | GPT-4o | Asistente con tools |
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## Inputs — preguntar al usuario si no los da
|
||||||
|
|
||||||
| Input | Requerido | Default | Ejemplo |
|
| Input | Requerido | Default | Ejemplo |
|
||||||
|-------|-----------|---------|---------|
|
|-------|-----------|---------|---------|
|
||||||
| `agent-id` | sí | — | `monitor-bot` |
|
| `agent-id` | si | — | `monitor-bot` |
|
||||||
| `display-name` | sí | — | `"Monitor Agent"` |
|
| `display-name` | si | — | `"Monitor Agent"` |
|
||||||
| `description` | sí | — | `"Monitorea servicios y reporta estado"` |
|
| `description` | si | — | `"Monitorea servicios y reporta estado"` |
|
||||||
| `llm.provider` | no | `openai` | `openai` o `anthropic` |
|
| `type` | no | `agent` | `agent` o `robot` |
|
||||||
| `llm.model` | no | `gpt-4o` | `gpt-4o`, `claude-sonnet-4-20250514` |
|
| `llm.provider` | no (N/A para robots) | `openai` | `openai` o `anthropic` |
|
||||||
| `tool_use` | no | `false` | `true` si necesita herramientas |
|
| `llm.model` | no (N/A para robots) | `gpt-4o` | `gpt-4o`, `claude-sonnet-4-20250514` |
|
||||||
| System prompt | sí | — | Texto describiendo rol y capacidades |
|
| `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
|
## Ruta rápida — script automatizado
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+294
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 <env>"},
|
||||||
|
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 <env>",
|
||||||
|
},
|
||||||
|
func(_ context.Context, msgCtx decision.MessageContext) string {
|
||||||
|
executed = true
|
||||||
|
if len(msgCtx.Args) == 0 {
|
||||||
|
return "Uso: !deploy <env>"
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+45
-29
@@ -158,8 +158,6 @@ func main() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
rules := rulesFor(cfg.Agent.ID, logger)
|
|
||||||
|
|
||||||
// Per-agent logger → writes to logs/<agent-id>/YYYY-MM-DD.jsonl
|
// Per-agent logger → writes to logs/<agent-id>/YYYY-MM-DD.jsonl
|
||||||
agentLogger, agentCleanup, aErr := agentlog.NewAgentLogger(agentlog.LoggerConfig{
|
agentLogger, agentCleanup, aErr := agentlog.NewAgentLogger(agentlog.LoggerConfig{
|
||||||
BaseDir: logDir,
|
BaseDir: logDir,
|
||||||
@@ -172,41 +170,59 @@ func main() {
|
|||||||
agentCleanup = func() {}
|
agentCleanup = func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve centralized ACL for this agent
|
// Branch: robot (command-only, lightweight) vs agent (full runtime).
|
||||||
agentACL := pksecurity.ResolveACL(cfg.Agent.ID, deps.secPolicy)
|
var runner agents.Runner
|
||||||
agentLogger.Debug("resolved acl for agent",
|
|
||||||
"agent", cfg.Agent.ID,
|
|
||||||
"acl_empty", agentACL.Empty(),
|
|
||||||
)
|
|
||||||
|
|
||||||
a, err := agents.New(cfg, rules, agentACL, agentLogger)
|
if cfg.Agent.Type == "robot" {
|
||||||
if err != nil {
|
robot, rErr := agents.NewRobot(cfg, agentLogger)
|
||||||
logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", err)
|
if rErr != nil {
|
||||||
agentCleanup()
|
logger.Error("failed to create robot", "id", cfg.Agent.ID, "err", rErr)
|
||||||
continue
|
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
|
// Resolve centralized ACL for this agent
|
||||||
a.SetBus(agentBus)
|
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
|
a, cErr := agents.New(cfg, rules, agentACL, agentLogger)
|
||||||
if orch != nil {
|
if cErr != nil {
|
||||||
a.SetInterceptor(orch.orchestrator.Intercept)
|
logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", cErr)
|
||||||
a.SetMembershipNotify(orch.orchestrator.NotifyMembership)
|
agentCleanup()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{
|
// Connect agent to bus for orchestration
|
||||||
ID: cfg.Agent.ID,
|
a.SetBus(agentBus)
|
||||||
MatrixUserID: cfg.Matrix.UserID,
|
|
||||||
Description: cfg.Agent.Description,
|
|
||||||
Capabilities: cfg.Agent.Tags,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Grab the first available Matrix client for room scanning
|
// If orchestrator is active, wire interceptor and membership notify
|
||||||
scannerOnce.set(a.RawMatrixClient())
|
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{
|
registry.register(&runningAgent{
|
||||||
agent: a,
|
runner: runner,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
cfgPath: path,
|
cfgPath: path,
|
||||||
logger: agentLogger,
|
logger: agentLogger,
|
||||||
|
|||||||
+60
-38
@@ -17,9 +17,9 @@ import (
|
|||||||
agentlog "github.com/enmanuel/agents/shell/logger"
|
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 {
|
type runningAgent struct {
|
||||||
agent *agents.Agent
|
runner agents.Runner
|
||||||
cfg *config.AgentConfig
|
cfg *config.AgentConfig
|
||||||
cfgPath string
|
cfgPath string
|
||||||
logger *slog.Logger
|
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) {
|
func (r *agentRegistry) register(ra *runningAgent) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.agents[ra.cfg.Agent.ID] = ra
|
r.agents[ra.cfg.Agent.ID] = ra
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
runtimeType := ra.cfg.Agent.Type
|
||||||
|
if runtimeType == "" {
|
||||||
|
runtimeType = "agent"
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ra.logger.Info("agent running")
|
ra.logger.Info("runner started", "type", runtimeType)
|
||||||
if err := ra.agent.Run(r.deps.parentCtx); err != nil {
|
if err := ra.runner.Run(r.deps.parentCtx); err != nil {
|
||||||
ra.logger.Error("agent stopped with error", "err", err)
|
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.
|
// Caller must NOT hold r.mu.
|
||||||
func (r *agentRegistry) stopAndWait(id string) {
|
func (r *agentRegistry) stopAndWait(id string) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -74,11 +79,11 @@ func (r *agentRegistry) stopAndWait(id string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ra.agent.Stop()
|
ra.runner.Stop()
|
||||||
select {
|
select {
|
||||||
case <-ra.agent.Done():
|
case <-ra.runner.Done():
|
||||||
case <-time.After(10 * time.Second):
|
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.
|
// 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() {}
|
newCleanup = func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Create new agent (validates config before discarding the old one).
|
// 5. Create new runner (validates config before discarding the old one).
|
||||||
rules := rulesFor(cfg.Agent.ID, newLogger)
|
var newRunner agents.Runner
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Wire bus and orchestration.
|
if cfg.Agent.Type == "robot" {
|
||||||
newAgent.SetBus(r.deps.agentBus)
|
robot, rErr := agents.NewRobot(cfg, newLogger)
|
||||||
if r.deps.orch != nil {
|
if rErr != nil {
|
||||||
newAgent.SetInterceptor(r.deps.orch.orchestrator.Intercept)
|
newLogger.Error("reload: failed to create robot", "id", id, "err", rErr)
|
||||||
newAgent.SetMembershipNotify(r.deps.orch.orchestrator.NotifyMembership)
|
newCleanup()
|
||||||
r.deps.orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{
|
return
|
||||||
ID: cfg.Agent.ID,
|
}
|
||||||
MatrixUserID: cfg.Matrix.UserID,
|
newRunner = robot
|
||||||
Description: cfg.Agent.Description,
|
} else {
|
||||||
Capabilities: cfg.Agent.Tags,
|
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{
|
newRA := &runningAgent{
|
||||||
agent: newAgent,
|
runner: newRunner,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
cfgPath: cfgPath,
|
cfgPath: cfgPath,
|
||||||
logger: newLogger,
|
logger: newLogger,
|
||||||
@@ -170,14 +188,18 @@ func (r *agentRegistry) reload(id string, rulesFor func(string, *slog.Logger) []
|
|||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
// 7. Start new goroutine.
|
// 7. Start new goroutine.
|
||||||
|
runtimeType := cfg.Agent.Type
|
||||||
|
if runtimeType == "" {
|
||||||
|
runtimeType = "agent"
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
newLogger.Info("agent running")
|
newLogger.Info("runner started", "type", runtimeType)
|
||||||
if err := newAgent.Run(r.deps.parentCtx); err != nil {
|
if err := newRunner.Run(r.deps.parentCtx); err != nil {
|
||||||
newLogger.Error("agent stopped with error", "err", err)
|
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.
|
// 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() {
|
func (r *agentRegistry) waitAll() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
dones := make([]<-chan struct{}, 0, len(r.agents))
|
dones := make([]<-chan struct{}, 0, len(r.agents))
|
||||||
for _, ra := range r.agents {
|
for _, ra := range r.agents {
|
||||||
dones = append(dones, ra.agent.Done())
|
dones = append(dones, ra.runner.Done())
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 32 | E2E: verificar skill /create-agent | [0032-e2e-create-agent-skill.md](0032-e2e-create-agent-skill.md) | pendiente |
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type AgentMeta struct {
|
|||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
|
Type string `yaml:"type"` // "agent" (default) or "robot" (command-only, no LLM)
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Template bool `yaml:"template"` // if true, launcher will skip this agent
|
Template bool `yaml:"template"` // if true, launcher will skip this agent
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
|
|||||||
Reference in New Issue
Block a user