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:
@@ -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
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user