package agents import ( "context" "fmt" "log/slog" "strings" "time" "github.com/enmanuel/agents/internal/config" "github.com/enmanuel/agents/pkg/command" "github.com/enmanuel/agents/pkg/decision" "github.com/enmanuel/agents/pkg/transport" "github.com/enmanuel/agents/shell/effects" "github.com/enmanuel/agents/shell/transportunibus" ) // Robot is a lightweight runtime for command-only bots. // Unlike Agent, it has no LLM, rules, memory, knowledge, skills, or tools. // It connects to the bus and dispatches commands; non-command messages are ignored. type Robot struct { cfg *config.AgentConfig transport *transportunibus.Transport sender effects.Sender logger *slog.Logger // 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 } // NewRobot creates a lightweight command-only bot from its config and logger. // It initializes the unibus transport and built-in commands. func NewRobot(cfg *config.AgentConfig, logger *slog.Logger) (*Robot, error) { tr, err := transportunibus.New(cfg.Bus, logger) if err != nil { return nil, fmt.Errorf("unibus transport: %w", err) } r := &Robot{ cfg: cfg, transport: tr, sender: tr.Sender(), logger: logger, 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() 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's transport 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.transport != nil { defer r.transport.Close() } r.logger.Info("robot starting", "id", r.cfg.Agent.ID, "name", r.cfg.Agent.Name, "type", "robot", "endpoint", r.transport.Endpoint(), ) return r.transport.Run(ctx, r.handleInbound) } // 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 } // handleInbound is called for each filtered incoming message. It carries no // mautrix types, so the robot core is transport-neutral. For a robot, only // commands are processed; all other messages are silently ignored. func (r *Robot) handleInbound(ctx context.Context, in transport.InboundMessage) { msgCtx := inboundToMsgCtx(in) roomID := in.RoomID // 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.sender.SendThreadMarkdown(ctx, roomID, threadID, eventID, markdown) } return r.sender.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) }