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
+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{