merge: issue/0030-robot-vs-agent — tipo Robot ligero para bots de comandos

This commit is contained in:
2026-04-08 23:20:25 +00:00
11 changed files with 811 additions and 85 deletions
+57
View File
@@ -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
View File
@@ -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)
}
+290
View File
@@ -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)
}
}
}
+20
View File
@@ -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)
}