feat: añadir sistema de comandos directos (!command)

Implementa pkg/command/ como core puro: tipos Spec/ParsedArgs, parser de
args key=value con soporte de comillas, specs de 8 comandos built-in
(help, tools, tool, ping, status, info, clear, version) y BuiltinNames()
para aliases.

En agents/runtime.go: nuevo flujo handleEvent que prioriza comandos sobre
LLM — custom rules del agente → built-in handlers → comando desconocido →
LLM fallback. Handlers en agents/commands.go. El comando !tool ejecuta
tools directamente via Registry con args key=value parseados.

LLM ahora es opcional: si no hay provider configurado, el agente corre
como simple_bot respondiendo solo a comandos.

Se extrae executeActions() como helper reutilizable para ambos flujos
(comando y no-comando).
This commit is contained in:
2026-03-07 01:11:26 +00:00
parent 515c26d56d
commit 33b11a63c8
6 changed files with 524 additions and 15 deletions
+180
View File
@@ -0,0 +1,180 @@
package agents
import (
"context"
"fmt"
"strings"
"time"
"github.com/enmanuel/agents/pkg/command"
"github.com/enmanuel/agents/pkg/decision"
)
// registerBuiltinCommands registers all built-in command handlers.
func (a *Agent) registerBuiltinCommands() {
a.commands["help"] = a.cmdHelp
a.commands["tools"] = a.cmdTools
a.commands["tool"] = a.cmdTool
a.commands["ping"] = a.cmdPing
a.commands["status"] = a.cmdStatus
a.commands["info"] = a.cmdInfo
a.commands["clear"] = a.cmdClear
a.commands["version"] = a.cmdVersion
}
// cmdHelp lists all available commands (built-in + custom rules with MatchCommand).
func (a *Agent) cmdHelp(_ context.Context, _ decision.MessageContext) string {
var b strings.Builder
b.WriteString("Comandos disponibles:\n\n")
// Built-in commands
for _, spec := range command.Builtins() {
if spec.Hidden {
continue
}
aliases := ""
if len(spec.Aliases) > 0 {
aliases = " (" + strings.Join(prefixAll(spec.Aliases, "!"), ", ") + ")"
}
fmt.Fprintf(&b, " %s%s — %s\n", spec.Usage, aliases, spec.Description)
}
// Custom commands from agent rules (rules named with MatchCommand pattern)
customRules := a.customCommandRules()
if len(customRules) > 0 {
b.WriteString("\nComandos del agente:\n")
for _, name := range customRules {
fmt.Fprintf(&b, " !%s\n", name)
}
}
return b.String()
}
// cmdTools lists all tools registered in the agent's tool registry.
func (a *Agent) cmdTools(_ context.Context, _ decision.MessageContext) string {
names := a.toolReg.Names()
if len(names) == 0 {
return "No hay tools registradas."
}
var b strings.Builder
fmt.Fprintf(&b, "Tools disponibles (%d):\n\n", len(names))
for _, name := range names {
t, _ := a.toolReg.Get(name)
fmt.Fprintf(&b, " %s — %s\n", t.Def.Name, t.Def.Description)
for _, p := range t.Def.Parameters {
req := ""
if p.Required {
req = " (requerido)"
}
fmt.Fprintf(&b, " %s: %s%s\n", p.Name, p.Description, req)
}
}
b.WriteString("\nUso: !tool <nombre> [key=value ...]")
return b.String()
}
// cmdTool executes a tool directly with key=value args.
func (a *Agent) cmdTool(ctx context.Context, msgCtx decision.MessageContext) string {
if len(msgCtx.Args) == 0 {
return "Uso: !tool <nombre> [key=value ...]\nUsa !tools para ver tools disponibles."
}
toolName := msgCtx.Args[0]
if _, ok := a.toolReg.Get(toolName); !ok {
return fmt.Sprintf("Tool %q no encontrada. Usa !tools para ver tools disponibles.", toolName)
}
// Parse remaining args as key=value
parsed := command.ParseArgs(msgCtx.Args[1:])
argsJSON := command.ArgsToJSON(parsed.Named)
a.logger.Info("executing tool via command",
"tool", toolName,
"args", argsJSON,
)
result := a.toolReg.Execute(ctx, toolName, argsJSON)
if result.Err != nil {
return fmt.Sprintf("Error ejecutando %s: %s", toolName, result.Err)
}
return fmt.Sprintf("%s:\n%s", toolName, result.Output)
}
// cmdPing responds with pong and timestamp.
func (a *Agent) cmdPing(_ context.Context, _ decision.MessageContext) string {
return fmt.Sprintf("pong — %s", time.Now().Format(time.RFC3339))
}
// cmdStatus shows agent uptime and active rooms.
func (a *Agent) cmdStatus(_ context.Context, _ decision.MessageContext) string {
uptime := time.Since(a.startTime).Truncate(time.Second)
a.windowsMu.RLock()
roomCount := len(a.windows)
a.windowsMu.RUnlock()
var b strings.Builder
fmt.Fprintf(&b, "Estado de %s:\n", a.cfg.Agent.Name)
fmt.Fprintf(&b, " Uptime: %s\n", uptime)
fmt.Fprintf(&b, " Rooms activos: %d\n", roomCount)
fmt.Fprintf(&b, " Window size: %d\n", a.windowSize)
fmt.Fprintf(&b, " Tools: %d\n", a.toolReg.Len())
if a.llm != nil {
fmt.Fprintf(&b, " LLM: %s/%s\n", a.cfg.LLM.Primary.Provider, a.cfg.LLM.Primary.Model)
} else {
b.WriteString(" LLM: no configurado\n")
}
return b.String()
}
// cmdInfo shows agent name, version, and description.
func (a *Agent) cmdInfo(_ context.Context, _ decision.MessageContext) string {
var b strings.Builder
fmt.Fprintf(&b, "Nombre: %s\n", a.cfg.Agent.Name)
fmt.Fprintf(&b, "ID: %s\n", a.cfg.Agent.ID)
if a.cfg.Agent.Version != "" {
fmt.Fprintf(&b, "Version: %s\n", a.cfg.Agent.Version)
}
fmt.Fprintf(&b, "Descripcion: %s\n", a.cfg.Agent.Description)
return b.String()
}
// cmdClear clears the conversation window for the current room.
func (a *Agent) cmdClear(_ context.Context, msgCtx decision.MessageContext) string {
a.ClearWindow(msgCtx.RoomID)
return "Ventana de conversacion limpiada."
}
// cmdVersion shows the agent version.
func (a *Agent) cmdVersion(_ context.Context, _ decision.MessageContext) string {
v := a.cfg.Agent.Version
if v == "" {
v = "sin version"
}
return fmt.Sprintf("%s %s", a.cfg.Agent.Name, v)
}
// customCommandRules extracts rule names that look like command handlers.
func (a *Agent) customCommandRules() []string {
var names []string
for _, r := range a.rules {
if r.Name != "" {
names = append(names, r.Name)
}
}
return names
}
// prefixAll adds a prefix to each string in a slice.
func prefixAll(ss []string, prefix string) []string {
out := make([]string, len(ss))
for i, s := range ss {
out[i] = prefix + s
}
return out
}
+83 -15
View File
@@ -9,11 +9,13 @@ import (
"os"
"path/filepath"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/command"
"github.com/enmanuel/agents/pkg/decision"
coretypes "github.com/enmanuel/agents/pkg/llm"
"github.com/enmanuel/agents/pkg/memory"
@@ -34,12 +36,15 @@ const (
defaultWindowSize = 20
)
// CommandHandler executes a built-in command and returns the response text.
type CommandHandler func(ctx context.Context, msgCtx decision.MessageContext) string
// Agent is the assembled runtime: pure core + impure shell.
type Agent struct {
cfg *config.AgentConfig
personality personality.Personality
rules []decision.Rule
llm coretypes.CompleteFunc
llm coretypes.CompleteFunc // nil when no LLM configured (simple_bot)
matrix *matrix.Client
runner *effects.Runner
listener *matrix.Listener
@@ -47,6 +52,11 @@ type Agent struct {
logger *slog.Logger
cryptoStore io.Closer // non-nil when E2EE is enabled; closed on shutdown
// Commands — built-in command handlers keyed by name (including aliases)
commands map[string]CommandHandler
cmdAliases map[string]string // alias → canonical name
startTime time.Time
// Memory
windows map[string]memory.Window
windowsMu sync.RWMutex
@@ -120,21 +130,26 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*
// SSH executor
sshExec := ssh.NewExecutor(cfg.SSH, logger)
// LLM client
llmLog := logger.With("component", "llm")
primaryLLM, err := shelllm.FromConfig(cfg.LLM.Primary, llmLog)
if err != nil {
return nil, fmt.Errorf("primary LLM: %w", err)
}
var llmFunc coretypes.CompleteFunc = primaryLLM
if cfg.LLM.Fallback.Provider != "" {
fallbackLLM, err := shelllm.FromConfig(cfg.LLM.Fallback, llmLog)
// LLM client — optional; if no provider is configured, the agent runs as simple_bot
var llmFunc coretypes.CompleteFunc
if cfg.LLM.Primary.Provider != "" {
llmLog := logger.With("component", "llm")
primaryLLM, err := shelllm.FromConfig(cfg.LLM.Primary, llmLog)
if err != nil {
logger.Warn("fallback LLM config error", "err", err)
} else {
llmFunc = shelllm.WithFallback(primaryLLM, fallbackLLM, cfg.LLM.Fallback, llmLog)
return nil, fmt.Errorf("primary LLM: %w", err)
}
llmFunc = primaryLLM
if cfg.LLM.Fallback.Provider != "" {
fallbackLLM, err := shelllm.FromConfig(cfg.LLM.Fallback, llmLog)
if err != nil {
logger.Warn("fallback LLM config error", "err", err)
} else {
llmFunc = shelllm.WithFallback(primaryLLM, fallbackLLM, cfg.LLM.Fallback, llmLog)
}
}
} else {
logger.Info("no LLM configured, running as command-only bot")
}
// Effects runner
@@ -194,6 +209,9 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*
toolReg: toolReg,
logger: logger,
cryptoStore: cryptoStore,
commands: make(map[string]CommandHandler),
cmdAliases: command.BuiltinNames(),
startTime: time.Now(),
windows: make(map[string]memory.Window),
memStore: memStore,
knowledgeStore: kStore,
@@ -201,6 +219,9 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*
roomCtx: roomCtx,
}
// Register built-in command handlers
a.registerBuiltinCommands()
// Register memory_clear_context with self as WindowClearer (after a is created)
if cfg.Tools.Memory.Enabled && memStore != nil {
toolReg.Register(tools.NewMemoryClearContext(a, roomCtx))
@@ -398,11 +419,46 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext,
defer a.matrix.SendTyping(ctx, roomID, false)
}
// ── Command flow ─────────────────────────────────────────────────
if msgCtx.Command != "" {
// 1. Custom rules from agent (can override built-ins)
actions := decision.Evaluate(msgCtx, a.rules)
if len(actions) > 0 {
a.logger.Debug("command matched custom rule", "command", msgCtx.Command)
a.executeActions(ctx, roomID, msgCtx, actions)
return
}
// 2. Built-in commands (resolve aliases first)
cmdName := msgCtx.Command
if canonical, ok := a.cmdAliases[cmdName]; ok {
cmdName = canonical
}
if handler, ok := a.commands[cmdName]; ok {
a.logger.Debug("executing built-in command", "command", cmdName)
reply := handler(ctx, msgCtx)
_ = a.matrix.SendText(ctx, roomID, reply)
return
}
// 3. Unknown command
a.logger.Debug("unknown command", "command", msgCtx.Command)
_ = a.matrix.SendText(ctx, roomID,
fmt.Sprintf("Comando desconocido: !%s. Usa !help para ver comandos disponibles.", msgCtx.Command))
return
}
// ── Non-command flow ─────────────────────────────────────────────
actions := decision.Evaluate(msgCtx, a.rules)
a.logger.Debug("rules evaluated", "matched_actions", len(actions))
// If no rules matched and the message mentions the bot or is a DM, use LLM.
if len(actions) == 0 && (msgCtx.IsMention || msgCtx.IsDirectMsg) {
if a.llm == nil {
// Simple bot: no LLM, ignore non-command messages
a.logger.Debug("no LLM configured, ignoring non-command message")
return
}
a.logger.Debug("no rules matched, falling back to LLM")
actions = []decision.Action{{
Kind: decision.ActionKindLLM,
@@ -418,10 +474,22 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext,
return
}
// Expand LLM actions inline — with tool-use loop when enabled
a.executeActions(ctx, roomID, msgCtx, actions)
}
// executeActions expands LLM actions and runs the effects runner.
func (a *Agent) executeActions(ctx context.Context, roomID string, msgCtx decision.MessageContext, actions []decision.Action) {
expanded := make([]decision.Action, 0, len(actions))
for _, act := range actions {
if act.Kind == decision.ActionKindLLM {
if a.llm == nil {
a.logger.Warn("LLM action requested but no LLM configured")
expanded = append(expanded, decision.Action{
Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{Content: "Este bot no tiene LLM configurado."},
})
continue
}
// Memory: load window + append user message before LLM call
a.ensureWindowLoaded(ctx, roomID)
a.appendToWindow(roomID, coretypes.Message{
+61
View File
@@ -0,0 +1,61 @@
package command
// Builtins returns the specs of all built-in commands. Pure.
func Builtins() []Spec {
return []Spec{
{
Name: "help",
Aliases: []string{"h"},
Description: "Lista comandos disponibles",
Usage: "!help",
},
{
Name: "tools",
Description: "Lista tools registradas con descripcion",
Usage: "!tools",
},
{
Name: "tool",
Description: "Ejecutar una tool directamente",
Usage: "!tool <nombre> [key=value ...]",
},
{
Name: "ping",
Description: "Alive check",
Usage: "!ping",
},
{
Name: "status",
Description: "Info del agente: uptime, rooms activos",
Usage: "!status",
},
{
Name: "info",
Description: "Nombre, version y descripcion del agente",
Usage: "!info",
},
{
Name: "clear",
Description: "Limpia ventana de conversacion del room actual",
Usage: "!clear",
},
{
Name: "version",
Aliases: []string{"v"},
Description: "Version del agente",
Usage: "!version",
},
}
}
// BuiltinNames returns just the command names (including aliases) for lookup. Pure.
func BuiltinNames() map[string]string {
m := make(map[string]string)
for _, spec := range Builtins() {
m[spec.Name] = spec.Name
for _, alias := range spec.Aliases {
m[alias] = spec.Name
}
}
return m
}
+91
View File
@@ -0,0 +1,91 @@
package command
import (
"encoding/json"
"strings"
)
// ParseArgs converts a slice of raw arguments into structured ParsedArgs.
// Supports: positional args, key=value pairs, and quoted values like key="hello world".
// Pure function — no side effects.
func ParseArgs(args []string) ParsedArgs {
p := ParsedArgs{
Named: make(map[string]string),
Raw: args,
}
// First, rejoin args to handle quoted values that were split by Fields().
joined := strings.Join(args, " ")
tokens := tokenize(joined)
for _, tok := range tokens {
if idx := strings.IndexByte(tok, '='); idx > 0 {
key := tok[:idx]
val := tok[idx+1:]
// Strip surrounding quotes from value
val = stripQuotes(val)
p.Named[key] = val
} else {
p.Positional = append(p.Positional, tok)
}
}
return p
}
// ArgsToJSON converts a named args map to a JSON string for tools.Registry.Execute.
// Pure function.
func ArgsToJSON(named map[string]string) string {
if len(named) == 0 {
return ""
}
m := make(map[string]any, len(named))
for k, v := range named {
m[k] = v
}
b, _ := json.Marshal(m)
return string(b)
}
// tokenize splits a string respecting quoted values.
// e.g. `host=server1 command="uptime -a"` → ["host=server1", `command="uptime -a"`]
func tokenize(s string) []string {
var tokens []string
var current strings.Builder
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(s); i++ {
ch := s[i]
switch {
case !inQuote && (ch == '"' || ch == '\''):
inQuote = true
quoteChar = ch
current.WriteByte(ch)
case inQuote && ch == quoteChar:
inQuote = false
current.WriteByte(ch)
case !inQuote && ch == ' ':
if current.Len() > 0 {
tokens = append(tokens, current.String())
current.Reset()
}
default:
current.WriteByte(ch)
}
}
if current.Len() > 0 {
tokens = append(tokens, current.String())
}
return tokens
}
// stripQuotes removes surrounding double or single quotes from a string.
func stripQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
+90
View File
@@ -0,0 +1,90 @@
package command
import (
"testing"
)
func TestParseArgs_Empty(t *testing.T) {
p := ParseArgs(nil)
if len(p.Positional) != 0 {
t.Errorf("expected 0 positional, got %d", len(p.Positional))
}
if len(p.Named) != 0 {
t.Errorf("expected 0 named, got %d", len(p.Named))
}
}
func TestParseArgs_Positional(t *testing.T) {
p := ParseArgs([]string{"ssh_command"})
if len(p.Positional) != 1 || p.Positional[0] != "ssh_command" {
t.Errorf("expected [ssh_command], got %v", p.Positional)
}
}
func TestParseArgs_Named(t *testing.T) {
p := ParseArgs([]string{"host=server1", "command=uptime"})
if p.Named["host"] != "server1" {
t.Errorf("expected host=server1, got %q", p.Named["host"])
}
if p.Named["command"] != "uptime" {
t.Errorf("expected command=uptime, got %q", p.Named["command"])
}
}
func TestParseArgs_QuotedValue(t *testing.T) {
p := ParseArgs([]string{`host=server1`, `command="uptime`, `-a"`})
if p.Named["host"] != "server1" {
t.Errorf("expected host=server1, got %q", p.Named["host"])
}
if p.Named["command"] != "uptime -a" {
t.Errorf("expected command='uptime -a', got %q", p.Named["command"])
}
}
func TestParseArgs_Mixed(t *testing.T) {
p := ParseArgs([]string{"ssh_command", "host=server1", "command=ls"})
if len(p.Positional) != 1 || p.Positional[0] != "ssh_command" {
t.Errorf("expected positional [ssh_command], got %v", p.Positional)
}
if p.Named["host"] != "server1" {
t.Errorf("expected host=server1, got %q", p.Named["host"])
}
}
func TestParseArgs_SingleQuotes(t *testing.T) {
p := ParseArgs([]string{`query='hello`, `world'`})
if p.Named["query"] != "hello world" {
t.Errorf("expected query='hello world', got %q", p.Named["query"])
}
}
func TestArgsToJSON_Empty(t *testing.T) {
result := ArgsToJSON(nil)
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
}
func TestArgsToJSON_Values(t *testing.T) {
result := ArgsToJSON(map[string]string{"host": "server1", "command": "uptime"})
if result == "" {
t.Error("expected non-empty JSON")
}
// Should contain both keys
if !contains(result, `"host"`) || !contains(result, `"server1"`) {
t.Errorf("JSON missing expected keys: %s", result)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+19
View File
@@ -0,0 +1,19 @@
// Package command defines pure types and functions for the bot command system.
// Commands are direct actions triggered by !prefix messages (e.g. !help, !ping).
package command
// Spec is the pure specification of a command. Only data, no side effects.
type Spec struct {
Name string
Aliases []string // e.g. ["h"] for help
Description string // short description for !help
Usage string // e.g. "!tool <name> [key=value ...]"
Hidden bool // do not show in !help
}
// ParsedArgs is the result of parsing "key=value key2=value2" arguments.
type ParsedArgs struct {
Positional []string // args without key=
Named map[string]string // args with key=value
Raw []string // original args
}