From 33b11a63c8206054a00e1741cdc596d504df5ab5 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sat, 7 Mar 2026 01:11:26 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20sistema=20de=20comandos?= =?UTF-8?q?=20directos=20(!command)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- agents/commands.go | 180 ++++++++++++++++++++++++++++++++++++++ agents/runtime.go | 98 +++++++++++++++++---- pkg/command/builtins.go | 61 +++++++++++++ pkg/command/parse.go | 91 +++++++++++++++++++ pkg/command/parse_test.go | 90 +++++++++++++++++++ pkg/command/types.go | 19 ++++ 6 files changed, 524 insertions(+), 15 deletions(-) create mode 100644 agents/commands.go create mode 100644 pkg/command/builtins.go create mode 100644 pkg/command/parse.go create mode 100644 pkg/command/parse_test.go create mode 100644 pkg/command/types.go diff --git a/agents/commands.go b/agents/commands.go new file mode 100644 index 0000000..4200d12 --- /dev/null +++ b/agents/commands.go @@ -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 [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 [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 +} diff --git a/agents/runtime.go b/agents/runtime.go index f846b58..2bfec41 100644 --- a/agents/runtime.go +++ b/agents/runtime.go @@ -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{ diff --git a/pkg/command/builtins.go b/pkg/command/builtins.go new file mode 100644 index 0000000..a5e9f77 --- /dev/null +++ b/pkg/command/builtins.go @@ -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 [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 +} diff --git a/pkg/command/parse.go b/pkg/command/parse.go new file mode 100644 index 0000000..915baa5 --- /dev/null +++ b/pkg/command/parse.go @@ -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 +} diff --git a/pkg/command/parse_test.go b/pkg/command/parse_test.go new file mode 100644 index 0000000..ce0a919 --- /dev/null +++ b/pkg/command/parse_test.go @@ -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 +} diff --git a/pkg/command/types.go b/pkg/command/types.go new file mode 100644 index 0000000..de250f2 --- /dev/null +++ b/pkg/command/types.go @@ -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 [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 +}