feat: sistema de prompts externos y mejoras en runtime de agentes

Añade pkg/command/prompts.go para cargar prompts desde archivos .md externos
en el directorio prompts/. Mejoras en agents/runtime.go para el manejo de
herramientas y flujo de ejecución. Nuevos comandos en agents/commands.go.
Ajustes menores en pkg/command/builtins.go para integrar las nuevas capacidades.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 17:17:00 +00:00
parent 6ae2e6be03
commit bb1e11f456
5 changed files with 141 additions and 25 deletions
+16
View File
@@ -19,6 +19,7 @@ func (a *Agent) registerBuiltinCommands() {
a.commands["status"] = a.cmdStatus
a.commands["info"] = a.cmdInfo
a.commands["clear"] = a.cmdClear
a.commands["prompts"] = a.cmdPrompts
a.commands["version"] = a.cmdVersion
}
@@ -155,6 +156,21 @@ func (a *Agent) cmdInfo(_ context.Context, _ decision.MessageContext) string {
return b.String()
}
// cmdPrompts lists available prompt-commands.
func (a *Agent) cmdPrompts(_ context.Context, _ decision.MessageContext) string {
if len(a.promptCmds) == 0 {
return "No hay prompt-commands disponibles."
}
var b strings.Builder
fmt.Fprintf(&b, "**Prompt-commands disponibles (%d):**\n\n", len(a.promptCmds))
for name := range a.promptCmds {
fmt.Fprintf(&b, "- `!%s`\n", name)
}
b.WriteString("\nUso: `!<nombre> [detalles adicionales...]`")
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)
+68 -25
View File
@@ -29,6 +29,14 @@ import (
shellmem "github.com/enmanuel/agents/shell/memory"
"github.com/enmanuel/agents/shell/ssh"
"github.com/enmanuel/agents/tools"
toolclock "github.com/enmanuel/agents/tools/clock"
toolfile "github.com/enmanuel/agents/tools/file"
toolhttp "github.com/enmanuel/agents/tools/http"
toolknowledge "github.com/enmanuel/agents/tools/knowledgetools"
toolmatrix "github.com/enmanuel/agents/tools/matrix"
toolmemory "github.com/enmanuel/agents/tools/memorytools"
toolssh "github.com/enmanuel/agents/tools/ssh"
toolweather "github.com/enmanuel/agents/tools/weather"
)
const (
@@ -63,7 +71,10 @@ type Agent struct {
windowsMu sync.RWMutex
memStore memory.Store // nil when memory is disabled
windowSize int
roomCtx *tools.RoomContext
roomCtx *toolmemory.RoomContext
// Prompt-commands — loaded from prompts/*.md at startup
promptCmds map[string]string // name → prompt content
// Knowledge store — non-nil when knowledge is enabled
knowledgeStore *shellknowledge.FileStore
@@ -73,7 +84,7 @@ type Agent struct {
}
// ClearWindow resets the conversation window for a room and deletes persisted
// messages from SQLite so the agent starts fresh. Implements tools.WindowClearer.
// messages from SQLite so the agent starts fresh. Implements toolmemory.WindowClearer.
func (a *Agent) ClearWindow(roomID string) {
a.windowsMu.Lock()
a.windows[roomID] = memory.NewWindow(a.windowSize)
@@ -159,7 +170,7 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*
// Memory subsystem
var memStore memory.Store
windowSize := defaultWindowSize
roomCtx := &tools.RoomContext{}
roomCtx := &toolmemory.RoomContext{}
if cfg.Memory.Enabled {
windowSize = cfg.Memory.WindowSize
@@ -223,9 +234,12 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*
// Register built-in command handlers
a.registerBuiltinCommands()
// Load prompt-commands from prompts/ directory
a.loadPromptCommands()
// 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))
toolReg.Register(toolmemory.NewMemoryClearContext(a, roomCtx))
}
// Matrix event listener
@@ -247,6 +261,26 @@ func (a *Agent) RegisterCommand(spec command.Spec, handler CommandHandler) {
a.logger.Info("command_registered", "command", spec.Name, "aliases", spec.Aliases)
}
// loadPromptCommands scans the project-root prompts/ directory and loads all .md files.
func (a *Agent) loadPromptCommands() {
prompts, err := command.LoadPromptCommands("prompts")
if err != nil {
a.logger.Warn("failed to load prompt-commands", "err", err)
return
}
a.promptCmds = make(map[string]string, len(prompts))
for _, p := range prompts {
a.promptCmds[p.Name] = p.Content
}
if len(a.promptCmds) > 0 {
names := make([]string, 0, len(a.promptCmds))
for n := range a.promptCmds {
names = append(names, n)
}
a.logger.Info("prompt-commands loaded", "count", len(a.promptCmds), "names", names)
}
}
// SetBus attaches the agent to the inter-agent bus for orchestration.
// Must be called before Run().
func (a *Agent) SetBus(b *bus.Bus) {
@@ -469,11 +503,20 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext,
return
}
// Unknown command — never falls through to rules or LLM
a.logger.Info("command_unknown", "command", msgCtx.Command)
_ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID,
fmt.Sprintf("Comando desconocido: `!%s`. Usa `!help` para ver comandos disponibles.", msgCtx.Command))
return
// Prompt-command: expand .md content and pass to LLM
if content, ok := a.promptCmds[cmdName]; ok {
a.logger.Info("prompt_command_expanded", "command", cmdName)
msgCtx.Content = command.ExpandPrompt(content, msgCtx.Args)
msgCtx.Command = ""
msgCtx.Args = nil
// Fall through to rules/LLM flow below
} else {
// Unknown command — never falls through to rules or LLM
a.logger.Info("command_unknown", "command", msgCtx.Command)
_ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID,
fmt.Sprintf("Comando desconocido: `!%s`. Usa `!help` para ver comandos disponibles.", msgCtx.Command))
return
}
}
// ── Non-command flow ─────────────────────────────────────────────
@@ -729,54 +772,54 @@ func buildToolRegistry(
matrixClient *matrix.Client,
memStore memory.Store,
kStore *shellknowledge.FileStore,
roomCtx *tools.RoomContext,
roomCtx *toolmemory.RoomContext,
logger *slog.Logger,
) *tools.Registry {
reg := tools.NewRegistry(logger)
if cfg.Tools.HTTP.Enabled {
reg.Register(tools.NewHTTPGet(cfg.Tools.HTTP))
reg.Register(tools.NewHTTPPost(cfg.Tools.HTTP))
reg.Register(toolhttp.NewHTTPGet(cfg.Tools.HTTP))
reg.Register(toolhttp.NewHTTPPost(cfg.Tools.HTTP))
logger.Debug("registered http tools")
}
if cfg.Tools.SSH.Enabled {
reg.Register(tools.NewSSHCommand(cfg.Tools.SSH, sshExec))
reg.Register(toolssh.NewSSHCommand(cfg.Tools.SSH, sshExec))
logger.Debug("registered ssh tool")
}
if cfg.Tools.FileOps.Enabled {
reg.Register(tools.NewReadFile(cfg.Tools.FileOps))
reg.Register(toolfile.NewReadFile(cfg.Tools.FileOps))
logger.Debug("registered file tool")
}
// current_time is always available
reg.Register(tools.NewCurrentTime())
reg.Register(toolclock.NewCurrentTime())
logger.Debug("registered current_time tool")
// weather tool is always available
reg.Register(tools.NewWeather())
reg.Register(toolweather.NewWeather())
logger.Debug("registered weather tool")
// matrix_send is always available
reg.Register(tools.NewMatrixSend(matrixClient))
reg.Register(toolmatrix.NewMatrixSend(matrixClient))
logger.Debug("registered matrix tool")
// Memory tools (memory_clear_context registered later since it needs the Agent)
if cfg.Tools.Memory.Enabled && memStore != nil {
reg.Register(tools.NewMemorySave(cfg.Agent.ID, memStore))
reg.Register(tools.NewMemoryRecall(cfg.Agent.ID, memStore))
reg.Register(tools.NewMemoryForget(cfg.Agent.ID, memStore))
reg.Register(tools.NewMemorySummary(cfg.Agent.ID, memStore))
reg.Register(toolmemory.NewMemorySave(cfg.Agent.ID, memStore))
reg.Register(toolmemory.NewMemoryRecall(cfg.Agent.ID, memStore))
reg.Register(toolmemory.NewMemoryForget(cfg.Agent.ID, memStore))
reg.Register(toolmemory.NewMemorySummary(cfg.Agent.ID, memStore))
logger.Debug("registered memory tools")
}
// Knowledge tools
if cfg.Tools.Knowledge.Enabled && kStore != nil {
reg.Register(tools.NewKnowledgeSearch(kStore))
reg.Register(tools.NewKnowledgeRead(kStore))
reg.Register(tools.NewKnowledgeWrite(kStore))
reg.Register(tools.NewKnowledgeList(kStore))
reg.Register(toolknowledge.NewKnowledgeSearch(kStore))
reg.Register(toolknowledge.NewKnowledgeRead(kStore))
reg.Register(toolknowledge.NewKnowledgeWrite(kStore))
reg.Register(toolknowledge.NewKnowledgeList(kStore))
logger.Debug("registered knowledge tools")
}
+5
View File
@@ -39,6 +39,11 @@ func Builtins() []Spec {
Description: "Limpia ventana de conversacion del room actual",
Usage: "!clear",
},
{
Name: "prompts",
Description: "Lista prompt-commands disponibles (archivos .md en prompts/)",
Usage: "!prompts",
},
{
Name: "version",
Aliases: []string{"v"},
+51
View File
@@ -0,0 +1,51 @@
package command
import (
"os"
"path/filepath"
"strings"
)
// PromptCommand maps a command name to its prompt content loaded from a .md file.
type PromptCommand struct {
Name string // filename without .md extension
Content string // file content (the prompt text)
}
// LoadPromptCommands scans dir for .md files and returns one PromptCommand per file.
// Returns nil (no error) if the directory does not exist.
func LoadPromptCommands(dir string) ([]PromptCommand, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var prompts []PromptCommand
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
name := strings.TrimSuffix(e.Name(), ".md")
prompts = append(prompts, PromptCommand{
Name: name,
Content: strings.TrimSpace(string(data)),
})
}
return prompts, nil
}
// ExpandPrompt builds the final message by concatenating the prompt content
// with any extra arguments the user provided after the command.
func ExpandPrompt(content string, args []string) string {
if len(args) == 0 {
return content
}
return content + "\n\n" + strings.Join(args, " ")
}
+1
View File
@@ -0,0 +1 @@
Hola!