docs: crear issues 0036-0041 — nuevas features del sistema

Issues planificados:
- 0036: Claude Code streaming de progreso en Matrix
- 0037: Agente que crea otros agentes/bots via Matrix
- 0038: Webapps y dashboards embebidos en Element via widgets
- 0039: Recordatorios dinámicos y crons que invocan agentes
- 0040: Soporte para mensajes de voz (audio → STT)
- 0041: Videollamadas con agentes via LiveKit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 21:19:09 +00:00
parent 9ec0c16038
commit 52d5632d89
22 changed files with 1557 additions and 0 deletions
+404
View File
@@ -0,0 +1,404 @@
package agents
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/enmanuel/agents/pkg/command"
"github.com/enmanuel/agents/pkg/decision"
"github.com/enmanuel/agents/shell/logger"
)
// 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["prompts"] = a.cmdPrompts
a.commands["version"] = a.cmdVersion
a.commands["metrics"] = a.cmdMetrics
}
// cmdHelp lists all available commands (built-in + agent-specific).
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
}
writeSpec(&b, spec)
}
// Agent-specific commands (registered via RegisterCommand)
if len(a.customSpecs) > 0 {
b.WriteString("\n**Comandos del agente:**\n\n")
for _, spec := range a.customSpecs {
if spec.Hidden {
continue
}
writeSpec(&b, spec)
}
}
return b.String()
}
// writeSpec formats a single command spec for the help output.
func writeSpec(b *strings.Builder, spec command.Spec) {
aliases := ""
if len(spec.Aliases) > 0 {
aliases = " (" + strings.Join(prefixAll(spec.Aliases, "!"), ", ") + ")"
}
usage := spec.Usage
if usage == "" {
usage = "!" + spec.Name
}
fmt.Fprintf(b, "- `%s`%s — %s\n", usage, aliases, spec.Description)
}
// 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.ExecuteForRoom(ctx, toolName, argsJSON, msgCtx.RoomID)
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\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 metadata, personality, capabilities, and configuration.
func (a *Agent) cmdInfo(_ context.Context, _ decision.MessageContext) string {
var b strings.Builder
// === Identidad ===
b.WriteString("## Identidad\n\n")
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)
if len(a.cfg.Agent.Tags) > 0 {
fmt.Fprintf(&b, "- **Tags:** %v\n", a.cfg.Agent.Tags)
}
// === Personalidad ===
if a.personality.Role != "" || a.personality.Communication.Personality != "" {
b.WriteString("\n## Personalidad\n\n")
if a.personality.Role != "" {
fmt.Fprintf(&b, "- **Rol:** %s\n", a.personality.Role)
}
if a.personality.Tone != "" {
fmt.Fprintf(&b, "- **Tono:** %s\n", a.personality.Tone)
}
if a.personality.Communication.Formality != "" {
fmt.Fprintf(&b, "- **Formalidad:** %s\n", a.personality.Communication.Formality)
}
if a.personality.Communication.Personality != "" {
fmt.Fprintf(&b, "- **Tipo:** %s\n", a.personality.Communication.Personality)
}
if a.personality.Communication.Humor != "" && a.personality.Communication.Humor != "none" {
fmt.Fprintf(&b, "- **Humor:** %s\n", a.personality.Communication.Humor)
}
}
// === LLM ===
if a.cfg.LLM.Primary.Provider != "" {
b.WriteString("\n## LLM\n\n")
fmt.Fprintf(&b, "- **Provider:** %s\n", a.cfg.LLM.Primary.Provider)
fmt.Fprintf(&b, "- **Modelo:** %s\n", a.cfg.LLM.Primary.Model)
if a.cfg.LLM.ToolUse.Enabled {
fmt.Fprintf(&b, "- **Tools:** habilitadas (max %d iteraciones)\n", a.cfg.LLM.ToolUse.MaxIterations)
}
}
// === Tools ===
toolCount := a.toolReg.Len()
if toolCount > 0 {
b.WriteString("\n## Tools disponibles\n\n")
fmt.Fprintf(&b, "- **Total:** %d tools\n", toolCount)
// Lista de tools (nombres)
toolNames := a.toolReg.Names()
if len(toolNames) > 0 && len(toolNames) <= 20 {
b.WriteString("- **Lista:** ")
for i, name := range toolNames {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "`%s`", name)
}
b.WriteString("\n")
}
}
// === Skills ===
if a.cfg.Skills.Enabled {
b.WriteString("\n## Skills\n\n")
b.WriteString("- **Habilitadas:** si\n")
if len(a.cfg.Skills.Categories) > 0 {
fmt.Fprintf(&b, "- **Categorias:** %v\n", a.cfg.Skills.Categories)
}
if a.skillLoader != nil {
if metas, err := a.skillLoader.LoadMeta(); err == nil {
fmt.Fprintf(&b, "- **Cantidad:** %d skills\n", len(metas))
}
}
}
// === Knowledge ===
hasPrivate := a.cfg.Tools.Knowledge.Enabled
hasShared := a.cfg.Tools.SharedKnowledge.Enabled
if hasPrivate || hasShared {
b.WriteString("\n## Knowledge\n\n")
if hasPrivate {
b.WriteString("- **Privado:** habilitado\n")
}
if hasShared {
b.WriteString("- **Compartido:** habilitado\n")
}
}
// === Memoria ===
if a.cfg.Memory.Enabled {
b.WriteString("\n## Memoria\n\n")
fmt.Fprintf(&b, "- **Habilitada:** si\n")
fmt.Fprintf(&b, "- **Window size:** %d mensajes\n", a.windowSize)
}
// === Schedules ===
if len(a.cfg.Schedules) > 0 {
b.WriteString("\n## Schedules\n\n")
fmt.Fprintf(&b, "- **Cron jobs:** %d configurados\n", len(a.cfg.Schedules))
}
// === Uptime ===
uptime := time.Since(a.startTime).Round(time.Second)
b.WriteString("\n## Uptime\n\n")
fmt.Fprintf(&b, "- **Activo desde:** %s\n", uptime)
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)
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)
}
// cmdMetrics aggregates today's log data and returns a summary table.
func (a *Agent) cmdMetrics(_ context.Context, _ decision.MessageContext) string {
if a.logDir == "" {
return "Metricas no disponibles: directorio de logs no configurado."
}
entries, err := logger.ReadDayLogs(a.logDir, a.cfg.Agent.ID, time.Now().UTC())
if err != nil || len(entries) == 0 {
return "No hay logs disponibles para hoy."
}
var (
totalMessages int
totalCommands int
totalLLMCalls int
totalLLMTokens int64
totalLLMLatency int64
totalToolCalls int
totalToolErrors int
totalErrors int
)
for _, raw := range entries {
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
continue
}
msg, _ := m["msg"].(string)
level, _ := m["level"].(string)
switch msg {
case "handling event":
totalMessages++
case "command_received":
totalCommands++
case "tool_exec_end":
totalToolCalls++
if d, ok := m["duration_ms"]; ok {
totalLLMLatency += toInt64(d) // reused for tool latency aggregation if needed
}
case "tool_exec_error":
totalToolCalls++
totalToolErrors++
case "LLM responded":
totalLLMCalls++
}
// Count tokens from any log line that has the field
if t, ok := m["tokens_used"]; ok {
totalLLMTokens += toInt64(t)
}
// Count LLM latency from duration_ms on LLM-related entries
if msg == "LLM responded" || msg == "LLM call failed" {
if d, ok := m["duration_ms"]; ok {
totalLLMLatency += toInt64(d)
}
}
if level == "ERROR" {
totalErrors++
}
}
var avgLLMLatency string
if totalLLMCalls > 0 {
avgLLMLatency = fmt.Sprintf("%d ms", totalLLMLatency/int64(totalLLMCalls))
} else {
avgLLMLatency = "n/a"
}
var b strings.Builder
fmt.Fprintf(&b, "**Metricas de %s — %s:**\n\n", a.cfg.Agent.Name, time.Now().UTC().Format("2006-01-02"))
b.WriteString("| Metrica | Valor |\n")
b.WriteString("|---------|-------|\n")
fmt.Fprintf(&b, "| Mensajes recibidos | %d |\n", totalMessages)
fmt.Fprintf(&b, "| Comandos ejecutados | %d |\n", totalCommands)
fmt.Fprintf(&b, "| Llamadas LLM | %d |\n", totalLLMCalls)
fmt.Fprintf(&b, "| Tokens LLM (total) | %d |\n", totalLLMTokens)
fmt.Fprintf(&b, "| Latencia LLM (media) | %s |\n", avgLLMLatency)
fmt.Fprintf(&b, "| Llamadas a tools | %d |\n", totalToolCalls)
fmt.Fprintf(&b, "| Errores de tools | %d |\n", totalToolErrors)
fmt.Fprintf(&b, "| Errores totales | %d |\n", totalErrors)
fmt.Fprintf(&b, "| Entradas de log | %d |\n", len(entries))
return b.String()
}
// toInt64 converts a JSON number (float64) to int64.
func toInt64(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case int64:
return n
case json.Number:
i, _ := n.Int64()
return i
default:
return 0
}
}
// 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
}
+129
View File
@@ -0,0 +1,129 @@
package agents
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/decision"
"github.com/enmanuel/agents/tools"
"log/slog"
)
// newMetricsTestAgent creates a minimal Agent for testing the !metrics command.
// Does NOT connect to Matrix or LLM.
func newMetricsTestAgent(logDir string) *Agent {
cfg := &config.AgentConfig{
Agent: config.AgentMeta{
ID: "test-bot",
Name: "Test Bot",
},
}
return &Agent{
cfg: cfg,
logDir: logDir,
toolReg: tools.NewRegistry(slog.Default()),
logger: slog.Default(),
startTime: time.Now(),
commands: make(map[string]CommandHandler),
cmdAliases: make(map[string]string),
}
}
func TestCmdMetrics_NoLogDir(t *testing.T) {
a := newMetricsTestAgent("")
result := a.cmdMetrics(context.Background(), decision.MessageContext{})
if !strings.Contains(result, "no configurado") {
t.Errorf("expected 'no configurado' message, got: %s", result)
}
}
func TestCmdMetrics_NoLogsToday(t *testing.T) {
dir := t.TempDir()
// Create the agent subdirectory but with no log files
agentDir := filepath.Join(dir, "test-bot")
os.MkdirAll(agentDir, 0o755)
a := newMetricsTestAgent(dir)
result := a.cmdMetrics(context.Background(), decision.MessageContext{})
if !strings.Contains(result, "No hay logs") {
t.Errorf("expected 'No hay logs' message, got: %s", result)
}
}
func TestCmdMetrics_AggregatesCorrectly(t *testing.T) {
dir := t.TempDir()
agentDir := filepath.Join(dir, "test-bot")
os.MkdirAll(agentDir, 0o755)
// Create a JSONL log file for today
today := time.Now().UTC().Format("2006-01-02")
logFile := filepath.Join(agentDir, today+".jsonl")
lines := []string{
`{"time":"2026-04-09T10:00:00Z","level":"DEBUG","msg":"handling event","agent_id":"test-bot","sender":"@user:example.com"}`,
`{"time":"2026-04-09T10:00:01Z","level":"INFO","msg":"command_received","agent_id":"test-bot","command":"help"}`,
`{"time":"2026-04-09T10:00:02Z","level":"DEBUG","msg":"handling event","agent_id":"test-bot","sender":"@user2:example.com"}`,
`{"time":"2026-04-09T10:01:00Z","level":"DEBUG","msg":"LLM responded","agent_id":"test-bot","content_len":100,"duration_ms":500}`,
`{"time":"2026-04-09T10:01:01Z","level":"DEBUG","msg":"LLM responded","agent_id":"test-bot","content_len":200,"duration_ms":300,"tokens_used":150}`,
`{"time":"2026-04-09T10:02:00Z","level":"INFO","msg":"tool_exec_end","agent_id":"test-bot","tool":"current_time","duration_ms":5}`,
`{"time":"2026-04-09T10:02:01Z","level":"WARN","msg":"tool_exec_error","agent_id":"test-bot","tool":"ssh_command","err":"timeout"}`,
`{"time":"2026-04-09T10:03:00Z","level":"ERROR","msg":"something_failed","agent_id":"test-bot"}`,
}
content := strings.Join(lines, "\n") + "\n"
if err := os.WriteFile(logFile, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
a := newMetricsTestAgent(dir)
result := a.cmdMetrics(context.Background(), decision.MessageContext{})
// Verify the output contains expected metrics
checks := map[string]string{
"messages": "| Mensajes recibidos | 2 |",
"commands": "| Comandos ejecutados | 1 |",
"llm_calls": "| Llamadas LLM | 2 |",
"llm_tokens": "| Tokens LLM (total) | 150 |",
"tool_calls": "| Llamadas a tools | 2 |",
"tool_errors": "| Errores de tools | 1 |",
"errors": "| Errores totales | 1 |",
"entries": "| Entradas de log | 8 |",
}
for name, expected := range checks {
if !strings.Contains(result, expected) {
t.Errorf("missing %s: expected %q in output:\n%s", name, expected, result)
}
}
}
func TestCmdMetrics_LLMLatencyAverage(t *testing.T) {
dir := t.TempDir()
agentDir := filepath.Join(dir, "test-bot")
os.MkdirAll(agentDir, 0o755)
today := time.Now().UTC().Format("2006-01-02")
logFile := filepath.Join(agentDir, today+".jsonl")
lines := []string{
`{"time":"2026-04-09T10:01:00Z","level":"DEBUG","msg":"LLM responded","duration_ms":400}`,
`{"time":"2026-04-09T10:01:01Z","level":"DEBUG","msg":"LLM responded","duration_ms":600}`,
}
content := strings.Join(lines, "\n") + "\n"
os.WriteFile(logFile, []byte(content), 0o644)
a := newMetricsTestAgent(dir)
result := a.cmdMetrics(context.Background(), decision.MessageContext{})
// Average of 400 and 600 = 500
if !strings.Contains(result, "| Latencia LLM (media) | 500 ms |") {
t.Errorf("expected average latency 500 ms in output:\n%s", result)
}
}
+391
View File
@@ -0,0 +1,391 @@
package agents
import (
"context"
"fmt"
"maunium.net/go/mautrix/event"
"github.com/enmanuel/agents/pkg/command"
"github.com/enmanuel/agents/pkg/decision"
coretypes "github.com/enmanuel/agents/pkg/llm"
"github.com/enmanuel/agents/pkg/orchestration"
"github.com/enmanuel/agents/pkg/sanitize"
"github.com/enmanuel/agents/shell/audit"
"github.com/enmanuel/agents/shell/bus"
)
// handleEvent is called by the matrix Listener for each filtered incoming event.
func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext, evt *event.Event) {
a.logger.Debug("handling event",
"sender", msgCtx.SenderID,
"is_dm", msgCtx.IsDirectMsg,
"is_mention", msgCtx.IsMention,
"command", msgCtx.Command,
)
roomID := evt.RoomID.String()
// Audit: message_received
a.emitAudit(audit.Event{
AgentID: a.cfg.Agent.ID,
EventType: audit.EventMessageReceived,
SenderID: msgCtx.SenderID,
RoomID: roomID,
Detail: fmt.Sprintf("is_dm=%v is_mention=%v", msgCtx.IsDirectMsg, msgCtx.IsMention),
})
// Update room context for memory tools
a.roomCtx.Set(roomID)
if a.cfg.Personality.Behavior.TypingIndicator {
_ = a.sender.SendTyping(ctx, roomID, true)
defer a.sender.SendTyping(ctx, roomID, false)
}
// ── Command flow ─────────────────────────────────────────────────
// Commands (!xxx) always resolve before rules or LLM. Never reach the LLM.
// Priority: built-in → unknown (agent-specific commands can be added via RegisterCommand).
if msgCtx.Command != "" {
a.logger.Info("command_received",
"command", msgCtx.Command,
"sender", msgCtx.SenderID,
"room", roomID,
"args", msgCtx.Args,
)
// Resolve aliases
cmdName := msgCtx.Command
if canonical, ok := a.cmdAliases[cmdName]; ok {
cmdName = canonical
}
if handler, ok := a.commands[cmdName]; ok {
// RBAC check for commands
if !a.acl.CanDo(msgCtx.SenderID, "command:"+cmdName) {
a.logger.Info("command_denied", "command", cmdName, "sender", msgCtx.SenderID)
_ = a.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID,
"No tienes permisos para ejecutar este comando.")
return
}
a.logger.Info("command_executed", "command", cmdName)
// Audit: command_exec
a.emitAudit(audit.Event{
AgentID: a.cfg.Agent.ID,
EventType: audit.EventCommandExec,
SenderID: msgCtx.SenderID,
RoomID: roomID,
Detail: fmt.Sprintf("command=%s", cmdName),
})
reply := handler(ctx, msgCtx)
_ = a.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID, reply)
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)
unknownMsg := fmt.Sprintf("Comando desconocido: `!%s`. Usa `!help` para ver comandos disponibles.", msgCtx.Command)
if a.cfg.Matrix.Filters.CommandPrefix == "" {
unknownMsg = fmt.Sprintf("Comando desconocido: `%s`. Usa `help` para ver comandos disponibles.", msgCtx.Command)
}
_ = a.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID, unknownMsg)
return
}
}
// ── Non-command flow ─────────────────────────────────────────────
// RBAC check for LLM access ("ask" action)
if !a.acl.CanDo(msgCtx.SenderID, "ask") {
a.logger.Info("ask_denied", "sender", msgCtx.SenderID)
_ = a.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID,
"No tienes permisos para interactuar con este agente.")
return
}
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,
LLM: &decision.LLMAction{ContextKey: msgCtx.RoomID},
}}
}
if len(actions) == 0 {
a.logger.Debug("no actions, ignoring message",
"is_dm", msgCtx.IsDirectMsg,
"is_mention", msgCtx.IsMention,
)
return
}
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) {
// Auto-thread: if configured and message is not already in a thread,
// start a new thread rooted at the user's message.
if a.cfg.Matrix.Threads.AutoThread && msgCtx.ThreadID == "" && msgCtx.EventID != "" {
msgCtx.ThreadID = msgCtx.EventID
}
// Sanitize user input before sending to LLM
sanitized, rejected := a.sanitizeInput(msgCtx.Content, roomID, msgCtx.SenderID)
if rejected {
a.runner.Execute(ctx, roomID, []decision.Action{{
Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{Content: "Tu mensaje fue rechazado por el filtro de seguridad.", InReplyTo: msgCtx.EventID, ThreadID: msgCtx.ThreadID},
}})
return
}
msgCtx.Content = sanitized
// Resolve memory key: use thread root as context key when inside a thread,
// so parallel threads in the same room have independent conversation windows.
memKey := roomID
if msgCtx.ThreadID != "" {
memKey = msgCtx.ThreadID
}
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.", InReplyTo: msgCtx.EventID, ThreadID: msgCtx.ThreadID},
})
continue
}
// Memory: load window + append user message before LLM call
a.ensureWindowLoaded(ctx, memKey)
a.appendToWindow(memKey, coretypes.Message{
Role: coretypes.RoleUser, Content: msgCtx.Content,
})
a.persistMessage(ctx, memKey, coretypes.RoleUser, msgCtx.Content)
reply, err := a.runLLM(ctx, msgCtx, memKey)
if err != nil {
a.logger.Error("llm error", "err", err)
expanded = append(expanded, decision.Action{
Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{Content: "Sorry, I encountered an error.", InReplyTo: msgCtx.EventID, ThreadID: msgCtx.ThreadID},
})
} else {
expanded = append(expanded, decision.Action{
Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{Content: reply, InReplyTo: msgCtx.EventID, ThreadID: msgCtx.ThreadID},
})
// Memory: append assistant reply after LLM call
a.appendToWindow(memKey, coretypes.Message{
Role: coretypes.RoleAssistant, Content: reply,
})
a.persistMessage(ctx, memKey, coretypes.RoleAssistant, reply)
}
} else {
expanded = append(expanded, act)
}
}
a.runner.Execute(ctx, roomID, expanded)
}
// listenBus processes messages from the inter-agent bus.
func (a *Agent) listenBus(ctx context.Context, ch <-chan bus.AgentMessage) {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
if msg.Kind == bus.KindTask {
a.handleTaskEvent(ctx, msg)
}
}
}
}
// handleTaskEvent processes a task delegated by the orchestrator.
// The bot generates a response and sends it both to Matrix and back via bus.
func (a *Agent) handleTaskEvent(ctx context.Context, msg bus.AgentMessage) {
taskJSON, ok := msg.Payload["task_json"]
if !ok {
a.logger.Error("task message missing task_json payload")
return
}
task, err := orchestration.UnmarshalTaskEvent(taskJSON)
if err != nil {
a.logger.Error("failed to unmarshal task event", "err", err)
return
}
a.logger.Info("handling orchestrated task",
"task_id", task.TaskID,
"room", task.TargetRoomID,
"sender", task.OriginalSender,
"iteration", task.Iteration,
)
roomID := task.TargetRoomID
// Update room context for memory tools
a.roomCtx.Set(roomID)
if a.cfg.Personality.Behavior.TypingIndicator {
_ = a.sender.SendTyping(ctx, roomID, true)
defer a.sender.SendTyping(ctx, roomID, false)
}
// Build a synthetic MessageContext from the task
msgCtx := decision.MessageContext{
SenderID: task.OriginalSender,
RoomID: roomID,
Content: task.OriginalQuestion,
IsDirectMsg: false,
IsMention: true, // treat orchestrated tasks like mentions
}
// If there are previous responses, prepend context
if len(task.PreviousResponses) > 0 {
var context string
for _, pr := range task.PreviousResponses {
context += fmt.Sprintf("[Previous response from %s]: %s\n\n", pr.BotID, pr.Text)
}
msgCtx.Content = context + "Original question: " + task.OriginalQuestion +
"\n\nPlease provide an improved or complementary answer."
}
// Sanitize orchestrated input
sanitized, rejected := a.sanitizeInput(msgCtx.Content, roomID, msgCtx.SenderID)
if rejected {
a.logger.Warn("orchestrated task rejected by sanitizer",
"task_id", task.TaskID, "sender", task.OriginalSender)
_ = a.sender.SendMarkdown(ctx, roomID, "El mensaje fue rechazado por el filtro de seguridad.")
return
}
msgCtx.Content = sanitized
// Load memory and run LLM
a.ensureWindowLoaded(ctx, roomID)
a.appendToWindow(roomID, coretypes.Message{
Role: coretypes.RoleUser, Content: msgCtx.Content,
})
reply, err := a.runLLM(ctx, msgCtx, roomID)
// Build the result to send back via bus
result := orchestration.TaskResult{
TaskID: task.TaskID,
BotID: a.cfg.Agent.ID,
}
if err != nil {
a.logger.Error("LLM error during orchestrated task", "err", err)
result.Error = err.Error()
reply = "Sorry, I encountered an error."
} else {
result.Text = reply
// Persist assistant reply
a.appendToWindow(roomID, coretypes.Message{
Role: coretypes.RoleAssistant, Content: reply,
})
a.persistMessage(ctx, roomID, coretypes.RoleAssistant, reply)
}
// Send reply to Matrix room
if sendErr := a.sender.SendMarkdown(ctx, roomID, reply); sendErr != nil {
a.logger.Error("failed to send orchestrated reply to Matrix", "err", sendErr)
}
// Send result back to orchestrator via bus
resultJSON, marshalErr := orchestration.MarshalTaskResult(result)
if marshalErr != nil {
a.logger.Error("failed to marshal task result", "err", marshalErr)
return
}
replyMsg := bus.AgentMessage{
From: bus.AgentID(a.cfg.Agent.ID),
To: msg.From,
Kind: bus.KindTaskResult,
Payload: map[string]string{"result_json": resultJSON},
}
if busErr := a.agentBus.Reply(task.TaskID, replyMsg); busErr != nil {
a.logger.Error("failed to send task result via bus", "err", busErr)
}
}
// sendReply sends a markdown reply that respects thread context.
// If threadID is non-empty, the reply is sent as part of that thread.
func (a *Agent) sendReply(ctx context.Context, roomID, eventID, threadID, markdown string) error {
if threadID != "" {
return a.sender.SendThreadMarkdown(ctx, roomID, threadID, eventID, markdown)
}
return a.sender.SendReplyMarkdown(ctx, roomID, eventID, markdown)
}
// parseSeverity converts a config string to sanitize.Severity.
func parseSeverity(s string) sanitize.Severity {
switch s {
case "high":
return sanitize.SeverityHigh
case "low":
return sanitize.SeverityLow
default:
return sanitize.SeverityMedium
}
}
// emitAudit writes an audit event if the audit writer is enabled.
func (a *Agent) emitAudit(evt audit.Event) {
if a.auditWriter != nil {
a.auditWriter.Emit(evt)
}
}
// sanitizeInput runs prompt injection detection on the message content.
// Returns the (possibly modified) content and true if the message should be rejected.
func (a *Agent) sanitizeInput(content, roomID, senderID string) (string, bool) {
if a.sanitizeOpts == nil {
return content, false
}
result := sanitize.Sanitize(content, *a.sanitizeOpts)
for _, w := range result.Warnings {
a.logger.Warn("prompt_injection_detected",
"pattern", w.PatternName,
"severity", w.Severity,
"matched", w.Matched,
"sender", senderID,
"room", roomID,
)
}
return result.Output, result.Rejected
}
+64
View File
@@ -0,0 +1,64 @@
package agents
import (
"context"
"testing"
"time"
)
// TestAgentStopAndDone verifies that Stop() cancels Run and Done() closes.
// Uses a minimal Agent (no Matrix, no LLM) via direct struct init so the test
// doesn't require network or external dependencies.
func TestAgentStopAndDone(t *testing.T) {
a := &Agent{
done: make(chan struct{}),
}
// Simulate Run: create the cancel, then immediately block on ctx.
ctx, cancel := context.WithCancel(context.Background())
a.cancel = cancel
started := make(chan struct{})
go func() {
close(started)
// Mimic what Run does: block on ctx, then close done.
<-ctx.Done()
close(a.done)
}()
<-started
// Stop must unblock the goroutine above.
a.Stop()
select {
case <-a.Done():
// ok
case <-time.After(2 * time.Second):
t.Fatal("Done() did not close within 2s after Stop()")
}
}
// TestAgentStopIdempotent verifies that calling Stop() multiple times is safe.
func TestAgentStopIdempotent(t *testing.T) {
a := &Agent{
done: make(chan struct{}),
}
_, cancel := context.WithCancel(context.Background())
a.cancel = cancel
defer cancel()
// Should not panic when called multiple times.
a.Stop()
a.Stop()
a.Stop()
}
// TestAgentStopNilCancel verifies Stop() is safe when cancel is nil.
func TestAgentStopNilCancel(t *testing.T) {
a := &Agent{
done: make(chan struct{}),
}
// cancel is nil — must not panic.
a.Stop()
}
+211
View File
@@ -0,0 +1,211 @@
package agents
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"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/personality"
"github.com/enmanuel/agents/shell/audit"
shelllm "github.com/enmanuel/agents/shell/llm"
)
// runLLM executes the LLM completion loop, including iterative tool-use.
func (a *Agent) runLLM(ctx context.Context, msgCtx decision.MessageContext, memKey string) (string, error) {
a.logger.Debug("calling LLM",
"model", a.cfg.LLM.Primary.Model,
"provider", a.cfg.LLM.Primary.Provider,
)
// Load system prompt from file if configured, else use description
systemPrompt := a.cfg.Agent.Description
if spFile := a.cfg.LLM.Reasoning.SystemPromptFile; spFile != "" {
// Resolve path relative to agent directory
spPath := filepath.Join("agents", a.cfg.Agent.ID, spFile)
if data, err := os.ReadFile(spPath); err == nil {
systemPrompt = string(data)
} else {
a.logger.Warn("failed to load system_prompt_file, using description", "path", spPath, "err", err)
}
}
// Concatenate personality prompt block
personalityBlock := personality.BuildPersonalityPrompt(a.personality)
if personalityBlock != "" {
systemPrompt = systemPrompt + "\n\n" + personalityBlock
}
// Build messages: conversation history from window (includes current user msg)
messages := a.getWindowMessages(memKey)
if len(messages) == 0 {
// Fallback if memory is disabled: just the current message
messages = []coretypes.Message{
{Role: coretypes.RoleUser, Content: msgCtx.Content},
}
}
// Build tool specs for the LLM if tool_use is enabled
var llmTools []coretypes.ToolSpec
if a.cfg.LLM.ToolUse.Enabled && a.toolReg.Len() > 0 {
llmTools = a.toolReg.ToLLMSpecs()
a.logger.Debug("tools available for LLM", "count", len(llmTools))
}
maxIter := a.cfg.LLM.ToolUse.MaxIterations
if maxIter <= 0 {
maxIter = defaultMaxToolIterations
}
// Tool-use loop: call LLM → execute tools → feed results back → repeat
for i := 0; i < maxIter; i++ {
req := coretypes.CompletionRequest{
Model: a.cfg.LLM.Primary.Model,
MaxTokens: a.cfg.LLM.Primary.MaxTokens,
Temperature: a.cfg.LLM.Primary.Temperature,
SystemPrompt: systemPrompt,
Messages: messages,
Tools: llmTools,
}
resp, err := a.llm(ctx, req)
if err != nil {
a.logger.Error("LLM call failed", "model", req.Model, "err", err)
// Audit: llm_error
a.emitAudit(audit.Event{
AgentID: a.cfg.Agent.ID,
EventType: audit.EventLLMError,
Detail: fmt.Sprintf("provider=%s model=%s error=%s", a.cfg.LLM.Primary.Provider, req.Model, err),
})
return "", err
}
a.logger.Debug("LLM responded",
"content_len", len(resp.Content),
"tool_calls", len(resp.ToolCalls),
"finish_reason", resp.FinishReason,
)
// Audit: llm_request
a.emitAudit(audit.Event{
AgentID: a.cfg.Agent.ID,
EventType: audit.EventLLMRequest,
Detail: fmt.Sprintf("provider=%s model=%s content_len=%d tool_calls=%d", a.cfg.LLM.Primary.Provider, req.Model, len(resp.Content), len(resp.ToolCalls)),
})
// No tool calls — return the text response
if len(resp.ToolCalls) == 0 {
return resp.Content, nil
}
// Append assistant message with tool calls to conversation
messages = append(messages, coretypes.Message{
Role: coretypes.RoleAssistant,
Content: resp.Content,
ToolCalls: resp.ToolCalls,
})
// Execute each tool and append results
for _, tc := range resp.ToolCalls {
a.logger.Info("executing tool",
"tool", tc.Name,
"call_id", tc.ID,
)
// RBAC check for tool execution
if !a.acl.CanDo(msgCtx.SenderID, "tool:"+tc.Name) {
a.logger.Info("tool_denied", "tool", tc.Name, "sender", msgCtx.SenderID)
messages = append(messages, coretypes.Message{
Role: coretypes.RoleTool,
Content: "error: permission denied for tool " + tc.Name,
ToolCallID: tc.ID,
})
continue
}
// Notify the room that a tool is being called (respect thread context)
toolNotice := fmt.Sprintf("\U0001f528 <em>%s</em>", tc.Name)
if err := a.sendReply(ctx, msgCtx.RoomID, msgCtx.EventID, msgCtx.ThreadID, toolNotice); err != nil {
a.logger.Warn("failed to send tool call notice", "tool", tc.Name, "err", err)
}
result := a.toolReg.ExecuteForRoom(ctx, tc.Name, tc.Arguments, msgCtx.RoomID)
output := result.Output
if result.Err != nil {
output = fmt.Sprintf("error: %s", result.Err)
a.logger.Warn("tool execution error",
"tool", tc.Name,
"err", result.Err,
)
} else {
a.logger.Debug("tool executed",
"tool", tc.Name,
"output_len", len(output),
)
}
messages = append(messages, coretypes.Message{
Role: coretypes.RoleTool,
Content: output,
ToolCallID: tc.ID,
})
}
}
// Max iterations reached — return whatever we have
a.logger.Warn("tool-use loop reached max iterations", "max", maxIter)
return "I've reached the maximum number of tool iterations. Here's what I found so far.", nil
}
// initLLM creates the LLM client function with optional fallback.
// Returns nil when no provider is configured (command-only bot).
func initLLM(cfg *config.AgentConfig, logger *slog.Logger) (coretypes.CompleteFunc, error) {
if cfg.LLM.Primary.Provider == "" {
logger.Info("no LLM configured, running as command-only bot")
return nil, nil
}
llmLog := logger.With("component", "llm")
primaryLLM, err := shelllm.FromConfig(cfg.LLM.Primary, llmLog)
if err != nil {
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)
}
}
return llmFunc, nil
}
// 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)
}
}
+119
View File
@@ -0,0 +1,119 @@
package agents
import (
"context"
"fmt"
"log/slog"
"path/filepath"
coretypes "github.com/enmanuel/agents/pkg/llm"
"github.com/enmanuel/agents/pkg/memory"
shellmem "github.com/enmanuel/agents/shell/memory"
)
// ClearWindow resets the conversation window for a room and deletes persisted
// 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)
a.windowsMu.Unlock()
if a.memStore != nil {
if err := a.memStore.DeleteMessages(
context.Background(), a.cfg.Agent.ID, &roomID,
); err != nil {
a.logger.Warn("failed to delete persisted messages on clear", "room", roomID, "err", err)
}
}
}
// ensureWindowLoaded loads the conversation window from SQLite on first access for a room.
func (a *Agent) ensureWindowLoaded(ctx context.Context, roomID string) {
a.windowsMu.Lock()
defer a.windowsMu.Unlock()
if _, ok := a.windows[roomID]; ok {
return
}
w := memory.NewWindow(a.windowSize)
if a.memStore != nil {
msgs, err := a.memStore.LoadMessages(ctx, a.cfg.Agent.ID, roomID, a.windowSize)
if err != nil {
a.logger.Warn("failed to load message history", "room", roomID, "err", err)
} else {
for _, m := range msgs {
w = w.Append(coretypes.Message{Role: m.Role, Content: m.Content})
}
if len(msgs) > 0 {
a.logger.Debug("loaded message history", "room", roomID, "count", len(msgs))
}
}
}
a.windows[roomID] = w
}
// appendToWindow adds a message to the in-memory conversation window.
func (a *Agent) appendToWindow(roomID string, msg coretypes.Message) {
a.windowsMu.Lock()
defer a.windowsMu.Unlock()
w, ok := a.windows[roomID]
if !ok {
w = memory.NewWindow(a.windowSize)
}
a.windows[roomID] = w.Append(msg)
}
// getWindowMessages returns a copy of the conversation window for a room.
func (a *Agent) getWindowMessages(roomID string) []coretypes.Message {
a.windowsMu.RLock()
defer a.windowsMu.RUnlock()
w, ok := a.windows[roomID]
if !ok {
return nil
}
return w.ToLLMMessages()
}
// persistMessage saves a message to the SQLite store (no-op if store is nil).
func (a *Agent) persistMessage(ctx context.Context, roomID string, role coretypes.Role, content string) {
if a.memStore == nil {
return
}
if err := a.memStore.SaveMessage(ctx, memory.HistoryMessage{
AgentID: a.cfg.Agent.ID,
RoomID: roomID,
Role: role,
Content: content,
}); err != nil {
a.logger.Warn("failed to persist message", "room", roomID, "err", err)
}
}
// memoryInit holds the results of memory subsystem initialization.
type memoryInit struct {
store memory.Store
windowSize int
}
// initMemoryStore creates the memory store and resolves window size from config.
// Returns a zero-value memoryInit if memory is disabled.
func initMemoryStore(enabled bool, windowSizeCfg int, dbPathCfg string, dataBase string, logger *slog.Logger) (memoryInit, error) {
if !enabled {
return memoryInit{windowSize: defaultWindowSize}, nil
}
windowSize := windowSizeCfg
if windowSize <= 0 {
windowSize = defaultWindowSize
}
dbPath := dbPathCfg
if dbPath == "" {
dbPath = filepath.Join(dataBase, "memory.db")
}
store, err := shellmem.New(dbPath, logger)
if err != nil {
return memoryInit{}, fmt.Errorf("memory store: %w", err)
}
logger.Info("memory enabled", "window_size", windowSize, "db", dbPath)
return memoryInit{store: store, windowSize: windowSize}, nil
}
+61
View File
@@ -0,0 +1,61 @@
// Package agents provides a global registry for agent rule factories.
//
// Each agent package self-registers via init() using Register.
// The launcher retrieves rules via GetRules without importing agent
// packages explicitly (only blank imports are needed).
package agents
import (
"sync"
"github.com/enmanuel/agents/pkg/decision"
)
// RulesFunc is a factory that returns the decision rules for an agent.
type RulesFunc func() []decision.Rule
var (
registryMu sync.RWMutex
registry = make(map[string]RulesFunc)
)
// Register adds a rule factory for the given agent ID.
// Intended to be called from init() in each agent package.
// Panics if the same ID is registered twice (catches copy-paste errors early).
func Register(id string, fn RulesFunc) {
registryMu.Lock()
defer registryMu.Unlock()
if _, exists := registry[id]; exists {
panic("agents.Register: duplicate agent id: " + id)
}
registry[id] = fn
}
// GetRules returns the rule factory for the given agent ID.
// Returns nil if no rules are registered (the agent is command-only).
func GetRules(id string) RulesFunc {
registryMu.RLock()
defer registryMu.RUnlock()
return registry[id]
}
// RegisteredIDs returns a sorted list of all registered agent IDs.
// Useful for debugging and diagnostics.
func RegisteredIDs() []string {
registryMu.RLock()
defer registryMu.RUnlock()
ids := make([]string, 0, len(registry))
for id := range registry {
ids = append(ids, id)
}
return ids
}
// resetRegistry clears all registrations (for testing only).
func resetRegistry() {
registryMu.Lock()
defer registryMu.Unlock()
registry = make(map[string]RulesFunc)
}
+276
View File
@@ -0,0 +1,276 @@
package agents
import (
"context"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/memory"
shellknowledge "github.com/enmanuel/agents/shell/knowledge"
shellmcp "github.com/enmanuel/agents/shell/mcp"
shellskills "github.com/enmanuel/agents/shell/skills"
"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"
toolimdb "github.com/enmanuel/agents/tools/imdb"
toolknowledge "github.com/enmanuel/agents/tools/knowledgetools"
toolmatrix "github.com/enmanuel/agents/tools/matrix"
toolmcp "github.com/enmanuel/agents/tools/mcptools"
toolmemory "github.com/enmanuel/agents/tools/memorytools"
toolskills "github.com/enmanuel/agents/tools/skilltools"
toolssh "github.com/enmanuel/agents/tools/ssh"
toolweather "github.com/enmanuel/agents/tools/weather"
"github.com/enmanuel/agents/shell/matrix"
)
// toolDeps holds external subsystem instances needed by the tool registry.
type toolDeps struct {
kStore *shellknowledge.FileStore
sharedKStore *shellknowledge.FileStore
mcpManager *shellmcp.Manager
skillLoader *shellskills.Loader
skillExecutor *shellskills.Executor
}
// initToolDeps initializes knowledge stores, MCP manager, and skills loader
// based on the agent config. All results are optional (nil when disabled).
func initToolDeps(cfg *config.AgentConfig, dataBase string, logger *slog.Logger) toolDeps {
var deps toolDeps
// Knowledge store
if cfg.Tools.Knowledge.Enabled {
knowledgeDir := cfg.Tools.Knowledge.Dir
if knowledgeDir == "" {
knowledgeDir = filepath.Join("agents", cfg.Agent.ID, "knowledge")
}
knowledgeDBPath := filepath.Join(dataBase, "knowledge.db")
kStore, kErr := shellknowledge.New(knowledgeDir, knowledgeDBPath, logger)
if kErr != nil {
logger.Error("knowledge_store_init_failed", "err", kErr)
} else {
if syncErr := kStore.Sync(context.Background()); syncErr != nil {
logger.Error("knowledge_sync_failed", "err", syncErr)
}
deps.kStore = kStore
}
}
// Shared knowledge store
if cfg.Tools.SharedKnowledge.Enabled {
sharedDir := cfg.Tools.SharedKnowledge.Dir
if sharedDir == "" {
sharedDir = "knowledges"
}
sharedDBPath := cfg.Tools.SharedKnowledge.DBPath
if sharedDBPath == "" {
sharedDBPath = "knowledges/data/knowledge.db"
}
sharedKStore, skErr := shellknowledge.New(sharedDir, sharedDBPath, logger)
if skErr != nil {
logger.Error("shared_knowledge_store_init_failed", "err", skErr)
} else {
if syncErr := sharedKStore.Sync(context.Background()); syncErr != nil {
logger.Error("shared_knowledge_sync_failed", "err", syncErr)
}
logger.Info("shared knowledge enabled", "dir", sharedDir, "db", sharedDBPath)
deps.sharedKStore = sharedKStore
}
}
// MCP client manager — connects to external MCP servers
if cfg.Tools.MCP.Enabled && len(cfg.Tools.MCP.Servers) > 0 {
mcpManager, mcpErr := shellmcp.NewManager(context.Background(), cfg.Tools.MCP.Servers, logger)
if mcpErr != nil {
logger.Error("mcp_manager_init_failed", "err", mcpErr)
} else {
logger.Info("mcp manager initialized", "servers", len(cfg.Tools.MCP.Servers))
deps.mcpManager = mcpManager
}
}
// Skills loader
if cfg.Skills.Enabled {
skillsPath := cfg.Skills.SkillsPath
if skillsPath == "" {
skillsPath = "skills/"
}
deps.skillLoader = shellskills.NewLoader(skillsPath)
// Skills executor for scripts
allowedInterpreters := cfg.Tools.Skills.AllowedInterpreters
timeout := cfg.Skills.Timeout
if timeout == 0 {
timeout = 60 * time.Second
}
deps.skillExecutor = shellskills.NewExecutor(allowedInterpreters, timeout)
logger.Info("skills enabled", "path", skillsPath, "categories", cfg.Skills.Categories)
}
return deps
}
// initRateLimiter configures the rate limiter on the tool registry if enabled.
func initRateLimiter(cfg *config.AgentConfig, toolReg *tools.Registry, logger *slog.Logger) {
if !cfg.Security.ToolRateLimit.Enabled {
return
}
maxCalls := cfg.Security.ToolRateLimit.MaxCallsPerMin
if maxCalls <= 0 {
maxCalls = 10
}
rl := tools.NewRateLimiter(maxCalls, time.Minute)
toolReg.SetRateLimiter(rl)
cleanupInterval := cfg.Security.ToolRateLimit.CleanupIntervalS
if cleanupInterval <= 0 {
cleanupInterval = 60
}
go func() {
ticker := time.NewTicker(time.Duration(cleanupInterval) * time.Second)
defer ticker.Stop()
for range ticker.C {
rl.Cleanup()
}
}()
logger.Info("tool rate limiting enabled", "max_calls_per_min", maxCalls)
}
// buildToolRegistry creates a Registry with tools enabled in the agent's config.
func buildToolRegistry(
cfg *config.AgentConfig,
sshExec *ssh.Executor,
matrixClient *matrix.Client,
memStore memory.Store,
kStore *shellknowledge.FileStore,
sharedKStore *shellknowledge.FileStore,
mcpManager *shellmcp.Manager,
skillLoader *shellskills.Loader,
skillExecutor *shellskills.Executor,
roomCtx *toolmemory.RoomContext,
logger *slog.Logger,
) *tools.Registry {
reg := tools.NewRegistry(logger)
if cfg.Tools.HTTP.Enabled {
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(toolssh.NewSSHCommand(cfg.Tools.SSH, sshExec))
logger.Debug("registered ssh tool")
}
if cfg.Tools.FileOps.Enabled {
reg.Register(toolfile.NewReadFile(cfg.Tools.FileOps))
reg.Register(toolfile.NewListDirectory(cfg.Tools.FileOps))
if !cfg.Tools.FileOps.ReadOnly {
reg.Register(toolfile.NewWriteFile(cfg.Tools.FileOps))
reg.Register(toolfile.NewAppendFile(cfg.Tools.FileOps))
reg.Register(toolfile.NewDeleteFile(cfg.Tools.FileOps))
}
logger.Debug("registered file tools")
}
// current_time is always available
reg.Register(toolclock.NewCurrentTime())
logger.Debug("registered current_time tool")
// weather tool is always available
reg.Register(toolweather.NewWeather())
logger.Debug("registered weather tool")
// imdb tool (enabled via config)
if cfg.Tools.IMDb.Enabled {
reg.Register(toolimdb.NewIMDbSearch(cfg.Tools.IMDb))
logger.Debug("registered imdb tool")
}
// matrix_send is always available
reg.Register(toolmatrix.NewMatrixSend(matrixClient, cfg.Tools.Matrix))
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(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(toolknowledge.NewKnowledgeSearch(kStore))
reg.Register(toolknowledge.NewKnowledgeRead(kStore))
reg.Register(toolknowledge.NewKnowledgeWrite(kStore))
reg.Register(toolknowledge.NewKnowledgeList(kStore))
logger.Debug("registered knowledge tools")
}
// Shared knowledge tools
if cfg.Tools.SharedKnowledge.Enabled && sharedKStore != nil {
sharedTools := toolknowledge.NewSharedKnowledgeTools(sharedKStore)
for _, tool := range sharedTools {
reg.Register(tool)
}
logger.Debug("registered shared knowledge tools", "count", len(sharedTools))
}
// MCP tools — register tools from all connected MCP servers
if mcpManager != nil {
for serverName, mcpClient := range mcpManager.AllClients() {
// Find the config for this server to get prefix, filter, timeout
var serverCfg *config.MCPServerCfg
for i := range cfg.Tools.MCP.Servers {
if cfg.Tools.MCP.Servers[i].Name == serverName {
serverCfg = &cfg.Tools.MCP.Servers[i]
break
}
}
if serverCfg == nil {
logger.Warn("no config found for MCP server", "name", serverName)
continue
}
// Convert and register MCP tools
mcpTools := toolmcp.FromMCPServer(mcpClient, serverCfg.Prefix, serverCfg.Tools, serverCfg.Timeout, logger)
for _, tool := range mcpTools {
reg.Register(tool)
}
logger.Debug("registered MCP tools", "server", serverName, "count", len(mcpTools))
}
}
// Skills tools — register skill search, load, read, and run tools
if skillLoader != nil {
reg.Register(toolskills.NewSkillSearch(skillLoader, cfg.Skills.Categories))
reg.Register(toolskills.NewSkillLoad(skillLoader))
reg.Register(toolskills.NewSkillReadResource(skillLoader))
if skillExecutor != nil {
reg.Register(toolskills.NewSkillRunScript(skillLoader, skillExecutor))
}
logger.Debug("registered skills tools")
}
return reg
}
// resolveDataBase returns the base directory for agent runtime data.
// Priority: config storage.base_path > $AGENTS_DATA_DIR/<id> > agents/<id>/data
func resolveDataBase(cfg *config.AgentConfig) string {
if cfg.Storage.BasePath != "" {
return cfg.Storage.BasePath
}
if envDir := os.Getenv("AGENTS_DATA_DIR"); envDir != "" {
return filepath.Join(envDir, cfg.Agent.ID)
}
return filepath.Join("agents", cfg.Agent.ID, "data")
}
+173
View File
@@ -0,0 +1,173 @@
package agents
import (
"log/slog"
"os"
"testing"
"github.com/enmanuel/agents/internal/config"
toolmemory "github.com/enmanuel/agents/tools/memorytools"
)
func TestBuildToolRegistry_MinimalConfig(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
cfg := &config.AgentConfig{
Agent: config.AgentMeta{ID: "test-agent"},
}
roomCtx := &toolmemory.RoomContext{}
reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger)
// Always-registered tools: current_time, weather, matrix_send
names := reg.Names()
if len(names) < 3 {
t.Fatalf("expected at least 3 always-on tools, got %d: %v", len(names), names)
}
assertToolRegistered(t, reg, "current_time")
assertToolRegistered(t, reg, "get_weather")
assertToolRegistered(t, reg, "matrix_send")
}
func TestBuildToolRegistry_HTTPEnabled(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
cfg := &config.AgentConfig{
Agent: config.AgentMeta{ID: "test-agent"},
Tools: config.ToolsCfg{
HTTP: config.HTTPToolCfg{Enabled: true, AllowedDomains: []string{"example.com"}},
},
}
roomCtx := &toolmemory.RoomContext{}
reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger)
assertToolRegistered(t, reg, "http_get")
assertToolRegistered(t, reg, "http_post")
}
func TestBuildToolRegistry_HTTPDisabled(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
cfg := &config.AgentConfig{
Agent: config.AgentMeta{ID: "test-agent"},
}
roomCtx := &toolmemory.RoomContext{}
reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger)
assertToolNotRegistered(t, reg, "http_get")
assertToolNotRegistered(t, reg, "http_post")
}
func TestBuildToolRegistry_FileOpsReadOnly(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
cfg := &config.AgentConfig{
Agent: config.AgentMeta{ID: "test-agent"},
Tools: config.ToolsCfg{
FileOps: config.FileOpsCfg{Enabled: true, ReadOnly: true, AllowedPaths: []string{"/tmp"}},
},
}
roomCtx := &toolmemory.RoomContext{}
reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger)
assertToolRegistered(t, reg, "read_file")
assertToolRegistered(t, reg, "list_directory")
assertToolNotRegistered(t, reg, "write_file")
assertToolNotRegistered(t, reg, "append_file")
assertToolNotRegistered(t, reg, "delete_file")
}
func TestBuildToolRegistry_FileOpsReadWrite(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
cfg := &config.AgentConfig{
Agent: config.AgentMeta{ID: "test-agent"},
Tools: config.ToolsCfg{
FileOps: config.FileOpsCfg{Enabled: true, ReadOnly: false, AllowedPaths: []string{"/tmp"}},
},
}
roomCtx := &toolmemory.RoomContext{}
reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger)
assertToolRegistered(t, reg, "read_file")
assertToolRegistered(t, reg, "list_directory")
assertToolRegistered(t, reg, "write_file")
assertToolRegistered(t, reg, "append_file")
assertToolRegistered(t, reg, "delete_file")
}
func TestBuildToolRegistry_IMDbEnabled(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
cfg := &config.AgentConfig{
Agent: config.AgentMeta{ID: "test-agent"},
Tools: config.ToolsCfg{
IMDb: config.IMDbToolCfg{Enabled: true},
},
}
roomCtx := &toolmemory.RoomContext{}
reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger)
assertToolRegistered(t, reg, "imdb_search")
}
func TestBuildToolRegistry_SSHEnabled(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
cfg := &config.AgentConfig{
Agent: config.AgentMeta{ID: "test-agent"},
Tools: config.ToolsCfg{
SSH: config.SSHToolCfg{Enabled: true},
},
}
roomCtx := &toolmemory.RoomContext{}
// SSH tool requires an executor; passing nil is fine for registration (only used at exec time)
reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger)
assertToolRegistered(t, reg, "ssh_command")
}
func TestBuildToolRegistry_ToolCount(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
// Enable everything that doesn't need external deps
cfg := &config.AgentConfig{
Agent: config.AgentMeta{ID: "test-agent"},
Tools: config.ToolsCfg{
HTTP: config.HTTPToolCfg{Enabled: true},
SSH: config.SSHToolCfg{Enabled: true},
FileOps: config.FileOpsCfg{Enabled: true, AllowedPaths: []string{"/tmp"}},
IMDb: config.IMDbToolCfg{Enabled: true},
},
}
roomCtx := &toolmemory.RoomContext{}
reg := buildToolRegistry(cfg, nil, nil, nil, nil, nil, nil, nil, nil, roomCtx, logger)
// 3 always-on + 2 HTTP + 1 SSH + 5 file + 1 IMDb = 12
expected := 12
if got := reg.Len(); got != expected {
t.Errorf("expected %d tools, got %d: %v", expected, got, reg.Names())
}
}
// ── Test helpers ────────────────────────────────────────────────────────────
func assertToolRegistered(t *testing.T, reg interface{ Names() []string }, name string) {
t.Helper()
for _, n := range reg.Names() {
if n == name {
return
}
}
t.Errorf("expected tool %q to be registered, but it was not. Registered: %v", name, reg.Names())
}
func assertToolNotRegistered(t *testing.T, reg interface{ Names() []string }, name string) {
t.Helper()
for _, n := range reg.Names() {
if n == name {
t.Errorf("expected tool %q NOT to be registered, but it was", name)
return
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package agents
import (
"sort"
"testing"
"github.com/enmanuel/agents/pkg/decision"
)
func TestRegisterAndGetRules(t *testing.T) {
resetRegistry()
called := false
fn := func() []decision.Rule {
called = true
return []decision.Rule{{Name: "test-rule"}}
}
Register("test-agent", fn)
got := GetRules("test-agent")
if got == nil {
t.Fatal("GetRules returned nil for registered agent")
}
rules := got()
if !called {
t.Error("rule factory was not called")
}
if len(rules) != 1 || rules[0].Name != "test-rule" {
t.Errorf("unexpected rules: %+v", rules)
}
}
func TestGetRulesMissing(t *testing.T) {
resetRegistry()
got := GetRules("nonexistent")
if got != nil {
t.Errorf("expected nil for unregistered agent, got %v", got)
}
}
func TestRegisterDuplicatePanics(t *testing.T) {
resetRegistry()
fn := func() []decision.Rule { return nil }
Register("dup-agent", fn)
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic on duplicate registration, got none")
}
msg, ok := r.(string)
if !ok {
t.Fatalf("expected string panic, got %T: %v", r, r)
}
if msg != "agents.Register: duplicate agent id: dup-agent" {
t.Errorf("unexpected panic message: %s", msg)
}
}()
Register("dup-agent", fn)
}
func TestRegisteredIDs(t *testing.T) {
resetRegistry()
Register("charlie", func() []decision.Rule { return nil })
Register("alpha", func() []decision.Rule { return nil })
Register("bravo", func() []decision.Rule { return nil })
ids := RegisteredIDs()
sort.Strings(ids)
expected := []string{"alpha", "bravo", "charlie"}
if len(ids) != len(expected) {
t.Fatalf("expected %d ids, got %d: %v", len(expected), len(ids), ids)
}
for i, id := range ids {
if id != expected[i] {
t.Errorf("id[%d] = %q, want %q", i, id, expected[i])
}
}
}
func TestResetRegistry(t *testing.T) {
resetRegistry()
Register("temp", func() []decision.Rule { return nil })
if GetRules("temp") == nil {
t.Fatal("expected registered agent")
}
resetRegistry()
if GetRules("temp") != nil {
t.Error("expected nil after reset")
}
if len(RegisteredIDs()) != 0 {
t.Error("expected empty registry after reset")
}
}
+299
View File
@@ -0,0 +1,299 @@
package agents
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"maunium.net/go/mautrix/event"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/command"
"github.com/enmanuel/agents/pkg/decision"
"github.com/enmanuel/agents/shell/matrix"
)
// Robot is a lightweight runtime for command-only bots.
// Unlike Agent, it has no LLM, rules, memory, knowledge, skills, or tools.
// It connects to Matrix and dispatches commands; non-command messages are ignored.
type Robot struct {
cfg *config.AgentConfig
matrix *matrix.Client
logger *slog.Logger
// E2EE crypto store — non-nil when encryption is enabled; closed on shutdown.
cryptoStore io.Closer
// Lifecycle
cancel context.CancelFunc
done chan struct{}
// Commands — handlers keyed by canonical name; aliases maps alias → canonical.
commands map[string]CommandHandler
cmdAliases map[string]string
customSpecs []command.Spec
startTime time.Time
// Personality prefix for replies
prefix string
// Matrix listener
listener *matrix.Listener
}
// NewRobot creates a lightweight command-only bot from its config and logger.
// It initializes only the Matrix client, E2EE (if configured), and built-in commands.
func NewRobot(cfg *config.AgentConfig, logger *slog.Logger) (*Robot, error) {
matrixClient, err := matrix.New(cfg.Matrix)
if err != nil {
return nil, fmt.Errorf("matrix client: %w", err)
}
// E2EE — initialize before the sync loop starts
var cryptoStore io.Closer
if cfg.Matrix.Encryption.Enabled {
storePath := filepath.Join(cfg.Matrix.Encryption.StorePath, "crypto.db")
pickleKey := os.Getenv(cfg.Matrix.Encryption.PickleKeyEnv)
logger.Info("initializing e2ee", "store", storePath)
cryptoStore, err = matrixClient.InitCrypto(context.Background(), storePath, pickleKey, cfg.Agent.ID)
if err != nil {
return nil, fmt.Errorf("e2ee init: %w", err)
}
// Auto-fetch cross-signing private keys from SSSS if recovery key is configured.
if envName := cfg.Matrix.Encryption.RecoveryKeyEnv; envName != "" {
if rk := os.Getenv(envName); rk != "" {
if err := matrixClient.FetchCrossSigningKeys(context.Background(), rk); err != nil {
logger.Warn("failed to fetch cross-signing keys from SSSS (non-fatal)", "err", err)
} else {
logger.Info("cross-signing private keys fetched from SSSS")
}
}
}
// Sign own device with the self-signing key so Element shows it as verified.
if err := matrixClient.SignOwnDevice(context.Background()); err != nil {
logger.Warn("failed to sign own device (non-fatal)", "err", err)
} else {
logger.Info("own device signed with cross-signing key")
}
logger.Info("e2ee ready")
}
r := &Robot{
cfg: cfg,
matrix: matrixClient,
logger: logger,
cryptoStore: cryptoStore,
done: make(chan struct{}),
commands: make(map[string]CommandHandler),
cmdAliases: command.BuiltinNames(),
startTime: time.Now(),
prefix: cfg.Personality.Prefix,
}
// Register built-in commands (robot-appropriate subset).
r.registerBuiltinCommands()
// Matrix event listener
r.listener = matrix.NewListener(matrixClient, cfg.Matrix, r.handleEvent, logger)
return r, nil
}
// registerBuiltinCommands registers command handlers appropriate for a robot.
// Robots support: help, ping, status, info, version.
// They do NOT support: tools, tool, clear, prompts (no LLM, no memory, no tools).
func (r *Robot) registerBuiltinCommands() {
r.commands["help"] = r.cmdHelp
r.commands["ping"] = r.cmdPing
r.commands["status"] = r.cmdStatus
r.commands["info"] = r.cmdInfo
r.commands["version"] = r.cmdVersion
}
// RegisterCommand adds a custom command handler for this robot.
func (r *Robot) RegisterCommand(spec command.Spec, handler CommandHandler) {
r.commands[spec.Name] = handler
r.cmdAliases[spec.Name] = spec.Name
for _, alias := range spec.Aliases {
r.cmdAliases[alias] = spec.Name
}
r.customSpecs = append(r.customSpecs, spec)
r.logger.Info("command_registered", "command", spec.Name, "aliases", spec.Aliases)
}
// Run starts the robot sync loop. Blocks until ctx is cancelled.
func (r *Robot) Run(ctx context.Context) error {
ctx, r.cancel = context.WithCancel(ctx)
defer close(r.done)
if r.cryptoStore != nil {
defer r.cryptoStore.Close()
}
r.logger.Info("robot starting",
"id", r.cfg.Agent.ID,
"name", r.cfg.Agent.Name,
"type", "robot",
)
// Set presence to online
if err := r.matrix.SetPresence(ctx, event.PresenceOnline); err != nil {
r.logger.Warn("failed to set presence online", "err", err)
}
defer func() {
offlineCtx := context.Background()
if err := r.matrix.SetPresence(offlineCtx, event.PresenceOffline); err != nil {
r.logger.Warn("failed to set presence offline", "err", err)
}
}()
return r.listener.Run(ctx)
}
// Stop cancels this robot's individual context, causing Run to return.
func (r *Robot) Stop() {
if r.cancel != nil {
r.cancel()
}
}
// Done returns a channel that is closed when Run has returned.
func (r *Robot) Done() <-chan struct{} {
return r.done
}
// handleEvent is called by the matrix Listener for each filtered incoming event.
// For a robot, only commands are processed; all other messages are silently ignored.
func (r *Robot) handleEvent(ctx context.Context, msgCtx decision.MessageContext, evt *event.Event) {
roomID := evt.RoomID.String()
// Only process commands. Non-command messages are silently ignored.
if msgCtx.Command == "" {
r.logger.Debug("non-command message, ignoring (robot)",
"sender", msgCtx.SenderID,
"room", roomID,
)
return
}
r.logger.Info("command_received",
"command", msgCtx.Command,
"sender", msgCtx.SenderID,
"room", roomID,
"args", msgCtx.Args,
)
// Resolve aliases
cmdName := msgCtx.Command
if canonical, ok := r.cmdAliases[cmdName]; ok {
cmdName = canonical
}
if handler, ok := r.commands[cmdName]; ok {
r.logger.Info("command_executed", "command", cmdName)
reply := handler(ctx, msgCtx)
_ = r.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID, reply)
return
}
// Unknown command
r.logger.Info("command_unknown", "command", msgCtx.Command)
unknownMsg := fmt.Sprintf("Comando desconocido: `!%s`. Usa `!help` para ver comandos disponibles.", msgCtx.Command)
if r.cfg.Matrix.Filters.CommandPrefix == "" {
unknownMsg = fmt.Sprintf("Comando desconocido: `%s`. Usa `help` para ver comandos disponibles.", msgCtx.Command)
}
_ = r.sendReply(ctx, roomID, msgCtx.EventID, msgCtx.ThreadID, unknownMsg)
}
// sendReply sends a markdown reply that respects thread context.
func (r *Robot) sendReply(ctx context.Context, roomID, eventID, threadID, markdown string) error {
if threadID != "" {
return r.matrix.SendThreadMarkdown(ctx, roomID, threadID, eventID, markdown)
}
return r.matrix.SendReplyMarkdown(ctx, roomID, eventID, markdown)
}
// ── Built-in command handlers (robot subset) ─────────────────────────────
func (r *Robot) cmdHelp(_ context.Context, _ decision.MessageContext) string {
var b strings.Builder
b.WriteString("**Comandos disponibles:**\n\n")
prefix := r.cfg.Matrix.Filters.CommandPrefix // "!" or ""
// Built-in commands appropriate for robots
robotBuiltins := []command.Spec{
{Name: "help", Aliases: []string{"h"}, Description: "Lista comandos disponibles", Usage: prefix + "help"},
{Name: "ping", Description: "Alive check", Usage: prefix + "ping"},
{Name: "status", Description: "Info del robot: uptime", Usage: prefix + "status"},
{Name: "info", Description: "Nombre, version y descripcion", Usage: prefix + "info"},
{Name: "version", Aliases: []string{"v"}, Description: "Version del robot", Usage: prefix + "version"},
}
for _, spec := range robotBuiltins {
writeSpec(&b, spec)
}
// Agent-specific commands (registered via RegisterCommand)
if len(r.customSpecs) > 0 {
b.WriteString("\n**Comandos del robot:**\n\n")
for _, spec := range r.customSpecs {
if spec.Hidden {
continue
}
writeSpec(&b, spec)
}
}
return b.String()
}
func (r *Robot) cmdPing(_ context.Context, _ decision.MessageContext) string {
return fmt.Sprintf("pong — %s", time.Now().Format(time.RFC3339))
}
func (r *Robot) cmdStatus(_ context.Context, _ decision.MessageContext) string {
uptime := time.Since(r.startTime).Truncate(time.Second)
var b strings.Builder
fmt.Fprintf(&b, "**Estado de %s:**\n\n", r.cfg.Agent.Name)
fmt.Fprintf(&b, "- **Tipo:** robot\n")
fmt.Fprintf(&b, "- **Uptime:** %s\n", uptime)
fmt.Fprintf(&b, "- **Comandos custom:** %d\n", len(r.customSpecs))
return b.String()
}
func (r *Robot) cmdInfo(_ context.Context, _ decision.MessageContext) string {
var b strings.Builder
b.WriteString("## Identidad\n\n")
fmt.Fprintf(&b, "- **Nombre:** %s\n", r.cfg.Agent.Name)
fmt.Fprintf(&b, "- **ID:** `%s`\n", r.cfg.Agent.ID)
fmt.Fprintf(&b, "- **Tipo:** robot\n")
if r.cfg.Agent.Version != "" {
fmt.Fprintf(&b, "- **Version:** %s\n", r.cfg.Agent.Version)
}
fmt.Fprintf(&b, "- **Descripcion:** %s\n", r.cfg.Agent.Description)
uptime := time.Since(r.startTime).Round(time.Second)
b.WriteString("\n## Uptime\n\n")
fmt.Fprintf(&b, "- **Activo desde:** %s\n", uptime)
return b.String()
}
func (r *Robot) cmdVersion(_ context.Context, _ decision.MessageContext) string {
v := r.cfg.Agent.Version
if v == "" {
v = "sin version"
}
return fmt.Sprintf("%s %s", r.cfg.Agent.Name, v)
}
+370
View File
@@ -0,0 +1,370 @@
package agents
import (
"context"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/command"
"github.com/enmanuel/agents/pkg/decision"
)
// newTestRobot creates a minimal Robot for testing without requiring
// Matrix or network. Fields are initialized directly. Uses standard "!" prefix.
func newTestRobot(t *testing.T) *Robot {
t.Helper()
return newTestRobotWithPrefix(t, "!")
}
// newTestRobotNoPrefix creates a minimal Robot with command_prefix: "" (no prefix).
func newTestRobotNoPrefix(t *testing.T) *Robot {
t.Helper()
return newTestRobotWithPrefix(t, "")
}
// newTestRobotWithPrefix creates a Robot with the given command prefix.
func newTestRobotWithPrefix(t *testing.T, prefix string) *Robot {
t.Helper()
cfg := &config.AgentConfig{
Agent: config.AgentMeta{
ID: "test-robot",
Name: "Test Robot",
Type: "robot",
Description: "robot for tests",
Version: "1.0.0",
},
Matrix: config.MatrixCfg{
Filters: config.FiltersCfg{
CommandPrefix: prefix,
},
},
}
r := &Robot{
cfg: cfg,
logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
done: make(chan struct{}),
commands: make(map[string]CommandHandler),
cmdAliases: command.BuiltinNames(),
startTime: time.Now(),
}
r.registerBuiltinCommands()
return r
}
// TestRobotCmdHelp verifies !help lists built-in commands.
func TestRobotCmdHelp(t *testing.T) {
r := newTestRobot(t)
reply := r.cmdHelp(context.Background(), decision.MessageContext{})
if !strings.Contains(reply, "Comandos disponibles") {
t.Error("help reply missing header")
}
for _, cmd := range []string{"help", "ping", "status", "info", "version"} {
if !strings.Contains(reply, "!"+cmd) {
t.Errorf("help reply missing command !%s", cmd)
}
}
// Robot should NOT show agent-only commands
for _, cmd := range []string{"!tools", "!tool", "!clear", "!prompts"} {
if strings.Contains(reply, cmd+"`") {
t.Errorf("help reply should not contain agent-only command %s", cmd)
}
}
}
// TestRobotCmdHelpWithCustom verifies !help includes custom commands.
func TestRobotCmdHelpWithCustom(t *testing.T) {
r := newTestRobot(t)
r.RegisterCommand(
command.Spec{Name: "deploy", Description: "Deploy to env", Usage: "!deploy <env>"},
func(_ context.Context, _ decision.MessageContext) string { return "deployed" },
)
reply := r.cmdHelp(context.Background(), decision.MessageContext{})
if !strings.Contains(reply, "Comandos del robot") {
t.Error("help reply missing 'Comandos del robot' section")
}
if !strings.Contains(reply, "!deploy") {
t.Error("help reply missing custom command !deploy")
}
}
// TestRobotCmdPing verifies !ping returns pong.
func TestRobotCmdPing(t *testing.T) {
r := newTestRobot(t)
reply := r.cmdPing(context.Background(), decision.MessageContext{})
if !strings.HasPrefix(reply, "pong") {
t.Errorf("ping reply should start with 'pong', got %q", reply)
}
}
// TestRobotCmdStatus verifies !status includes type and uptime.
func TestRobotCmdStatus(t *testing.T) {
r := newTestRobot(t)
reply := r.cmdStatus(context.Background(), decision.MessageContext{})
if !strings.Contains(reply, "robot") {
t.Error("status reply missing type 'robot'")
}
if !strings.Contains(reply, "Uptime") {
t.Error("status reply missing Uptime")
}
}
// TestRobotCmdInfo verifies !info shows robot identity.
func TestRobotCmdInfo(t *testing.T) {
r := newTestRobot(t)
reply := r.cmdInfo(context.Background(), decision.MessageContext{})
if !strings.Contains(reply, "Test Robot") {
t.Error("info reply missing robot name")
}
if !strings.Contains(reply, "test-robot") {
t.Error("info reply missing robot ID")
}
if !strings.Contains(reply, "robot") {
t.Error("info reply missing type 'robot'")
}
}
// TestRobotCmdVersion verifies !version returns name + version.
func TestRobotCmdVersion(t *testing.T) {
r := newTestRobot(t)
reply := r.cmdVersion(context.Background(), decision.MessageContext{})
if reply != "Test Robot 1.0.0" {
t.Errorf("version reply = %q, want %q", reply, "Test Robot 1.0.0")
}
}
// TestRobotIgnoresNonCommand verifies that handleEvent silently ignores
// non-command messages (no error, no reply).
func TestRobotIgnoresNonCommand(t *testing.T) {
r := newTestRobot(t)
// handleEvent with empty Command should not panic.
// Since we can't easily mock the Matrix client, we verify the method
// returns without error by checking it doesn't reach command dispatch.
msgCtx := decision.MessageContext{
Command: "", // non-command
Content: "hola bot",
}
// The robot should just return without doing anything.
// We can't call handleEvent directly because it needs an *event.Event,
// but we can verify the logic by checking the command map behavior.
if _, ok := r.commands[""]; ok {
t.Error("empty string should not be a registered command")
}
// Verify no commands match empty string.
if _, ok := r.cmdAliases[""]; ok {
t.Error("empty string should not be in aliases")
}
_ = msgCtx // used to document test intent
}
// TestRobotCustomCommand verifies RegisterCommand works and the handler executes.
func TestRobotCustomCommand(t *testing.T) {
r := newTestRobot(t)
executed := false
r.RegisterCommand(
command.Spec{
Name: "deploy",
Aliases: []string{"d"},
Description: "Deploy to env",
Usage: "!deploy <env>",
},
func(_ context.Context, msgCtx decision.MessageContext) string {
executed = true
if len(msgCtx.Args) == 0 {
return "Uso: !deploy <env>"
}
return "Deploying to " + msgCtx.Args[0]
},
)
// Verify command is registered
handler, ok := r.commands["deploy"]
if !ok {
t.Fatal("deploy command not registered")
}
// Execute the handler
reply := handler(context.Background(), decision.MessageContext{
Command: "deploy",
Args: []string{"staging"},
})
if !executed {
t.Error("handler was not executed")
}
if reply != "Deploying to staging" {
t.Errorf("reply = %q, want %q", reply, "Deploying to staging")
}
// Verify alias works
canonical, ok := r.cmdAliases["d"]
if !ok {
t.Fatal("alias 'd' not registered")
}
if canonical != "deploy" {
t.Errorf("alias canonical = %q, want %q", canonical, "deploy")
}
// Verify custom spec is tracked (for !help)
if len(r.customSpecs) != 1 {
t.Fatalf("customSpecs len = %d, want 1", len(r.customSpecs))
}
if r.customSpecs[0].Name != "deploy" {
t.Errorf("customSpecs[0].Name = %q, want %q", r.customSpecs[0].Name, "deploy")
}
}
// TestRobotStopAndDone verifies lifecycle methods work correctly.
func TestRobotStopAndDone(t *testing.T) {
r := &Robot{
done: make(chan struct{}),
}
ctx, cancel := context.WithCancel(context.Background())
r.cancel = cancel
started := make(chan struct{})
go func() {
close(started)
<-ctx.Done()
close(r.done)
}()
<-started
r.Stop()
select {
case <-r.Done():
// ok
case <-time.After(2 * time.Second):
t.Fatal("Done() did not close within 2s after Stop()")
}
}
// TestRobotStopNilCancel verifies Stop is safe when cancel is nil.
func TestRobotStopNilCancel(t *testing.T) {
r := &Robot{
done: make(chan struct{}),
}
// cancel is nil — must not panic.
r.Stop()
}
// TestRunnerInterfaceSatisfied verifies that both Agent and Robot
// satisfy the Runner interface at compile time.
func TestRunnerInterfaceSatisfied(t *testing.T) {
// These are compile-time checks — if they compile, the test passes.
var _ Runner = (*Agent)(nil)
var _ Runner = (*Robot)(nil)
}
// TestRobotBuiltinCommandCount verifies the robot has exactly the expected
// built-in commands and not more.
func TestRobotBuiltinCommandCount(t *testing.T) {
r := newTestRobot(t)
expected := map[string]bool{
"help": true,
"ping": true,
"status": true,
"info": true,
"version": true,
}
for name := range r.commands {
if !expected[name] {
t.Errorf("unexpected built-in command %q in robot", name)
}
}
for name := range expected {
if _, ok := r.commands[name]; !ok {
t.Errorf("missing built-in command %q in robot", name)
}
}
}
// ── No-prefix command tests ───────────────────────────────────────────────
// TestRobotNoPrefixCmdHelp verifies that help shows commands without ! prefix
// when command_prefix is "".
func TestRobotNoPrefixCmdHelp(t *testing.T) {
r := newTestRobotNoPrefix(t)
reply := r.cmdHelp(context.Background(), decision.MessageContext{})
if !strings.Contains(reply, "Comandos disponibles") {
t.Error("help reply missing header")
}
// Commands should appear WITHOUT ! prefix
for _, cmd := range []string{"help", "ping", "status", "info", "version"} {
// Should contain the command name
if !strings.Contains(reply, cmd) {
t.Errorf("help reply missing command %s", cmd)
}
// Should NOT contain "!cmd" as usage (but might contain it elsewhere)
if strings.Contains(reply, "!"+cmd) {
t.Errorf("help reply should not show !%s in no-prefix mode", cmd)
}
}
}
// TestRobotNoPrefixCmdHelpWithCustom verifies custom commands in no-prefix mode.
func TestRobotNoPrefixCmdHelpWithCustom(t *testing.T) {
r := newTestRobotNoPrefix(t)
r.RegisterCommand(
command.Spec{Name: "deploy", Description: "Deploy to env", Usage: "deploy <env>"},
func(_ context.Context, _ decision.MessageContext) string { return "deployed" },
)
reply := r.cmdHelp(context.Background(), decision.MessageContext{})
if !strings.Contains(reply, "Comandos del robot") {
t.Error("help reply missing 'Comandos del robot' section")
}
if !strings.Contains(reply, "deploy") {
t.Error("help reply missing custom command deploy")
}
}
// TestRobotNoPrefixSameBuiltins verifies that no-prefix robots have the
// same set of built-in commands as standard robots.
func TestRobotNoPrefixSameBuiltins(t *testing.T) {
standard := newTestRobot(t)
noPrefix := newTestRobotNoPrefix(t)
if len(standard.commands) != len(noPrefix.commands) {
t.Errorf("command count mismatch: standard=%d, noPrefix=%d",
len(standard.commands), len(noPrefix.commands))
}
for name := range standard.commands {
if _, ok := noPrefix.commands[name]; !ok {
t.Errorf("no-prefix robot missing command %q", name)
}
}
}
+414
View File
@@ -0,0 +1,414 @@
// Package agents defines the Agent runtime that ties core and shell together.
package agents
import (
"context"
"fmt"
"io"
"log/slog"
"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/acl"
"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"
"github.com/enmanuel/agents/pkg/personality"
"github.com/enmanuel/agents/pkg/sanitize"
"github.com/enmanuel/agents/shell/audit"
"github.com/enmanuel/agents/shell/bus"
shellcron "github.com/enmanuel/agents/shell/cron"
"github.com/enmanuel/agents/shell/effects"
shellknowledge "github.com/enmanuel/agents/shell/knowledge"
"github.com/enmanuel/agents/shell/matrix"
shellmcp "github.com/enmanuel/agents/shell/mcp"
shellskills "github.com/enmanuel/agents/shell/skills"
"github.com/enmanuel/agents/shell/ssh"
"github.com/enmanuel/agents/tools"
toolmemory "github.com/enmanuel/agents/tools/memorytools"
)
const (
defaultMaxToolIterations = 5
defaultWindowSize = 20
)
// Option configures optional Agent behaviour.
type Option func(*Agent)
// WithLogDir sets the base directory for JSONL logs (used by !metrics command).
func WithLogDir(dir string) Option {
return func(a *Agent) { a.logDir = dir }
}
// 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 // nil when no LLM configured (simple_bot)
matrix *matrix.Client
sender effects.MatrixSender // used by sendReply; same object as matrix in production
runner *effects.Runner
listener *matrix.Listener
toolReg *tools.Registry
logger *slog.Logger
cryptoStore io.Closer // non-nil when E2EE is enabled; closed on shutdown
mcpManager *shellmcp.Manager // nil when MCP client is disabled
// Lifecycle — cancel stops this agent individually; done is closed when Run returns.
cancel context.CancelFunc
done chan struct{}
// Access control
acl acl.ACL
// Commands — handlers keyed by canonical name; cmdAliases maps alias → canonical
commands map[string]CommandHandler
cmdAliases map[string]string // alias → canonical name
customSpecs []command.Spec // specs from RegisterCommand (for !help)
startTime time.Time
// Memory
windows map[string]memory.Window
windowsMu sync.RWMutex
memStore memory.Store // nil when memory is disabled
windowSize int
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
// Shared knowledge store — non-nil when shared_knowledge is enabled
sharedKnowledgeStore *shellknowledge.FileStore
// Skills loader — non-nil when skills are enabled
skillLoader *shellskills.Loader
// Sanitization options — nil when sanitization is disabled
sanitizeOpts *sanitize.Options
// Bus — set via SetBus() when running under the unified launcher
agentBus *bus.Bus
// Scheduler — nil when no schedules are configured
scheduler *shellcron.Scheduler
// Audit writer — nil when audit is disabled
auditWriter *audit.Writer
// LogDir — base directory for JSONL logs (used by !metrics)
logDir string
}
// New assembles an Agent from its config, rules, pre-resolved ACL, and logger.
// The ACL is resolved externally (e.g. from security/ YAML files) and injected here.
// Pass acl.ACL{} (empty) for open access (no restrictions).
// logDir is the base directory for JSONL logs (used by !metrics command); empty disables metrics.
func New(cfg *config.AgentConfig, rules []decision.Rule, agentACL acl.ACL, logger *slog.Logger, opts ...Option) (*Agent, error) {
// Matrix client
matrixClient, err := matrix.New(cfg.Matrix)
if err != nil {
return nil, fmt.Errorf("matrix client: %w", err)
}
// E2EE — initialize before the sync loop starts
cryptoStore, err := initCrypto(cfg, matrixClient, logger)
if err != nil {
return nil, err
}
// SSH executor
sshExec := ssh.NewExecutor(cfg.SSH, logger)
// LLM client — optional; if no provider is configured, the agent runs as simple_bot
llmFunc, err := initLLM(cfg, logger)
if err != nil {
return nil, err
}
// Effects runner
runner := effects.NewRunner(matrixClient, sshExec, logger)
// Resolve base data path for this agent
dataBase := resolveDataBase(cfg)
logger.Debug("data base path", "path", dataBase)
// Memory subsystem
memInit, err := initMemoryStore(cfg.Memory.Enabled, cfg.Memory.WindowSize, cfg.Memory.DBPath, dataBase, logger)
if err != nil {
return nil, err
}
// Tool dependencies (knowledge, MCP, skills)
deps := initToolDeps(cfg, dataBase, logger)
if !agentACL.Empty() {
logger.Info("acl enabled (centralized security policy)")
}
// Tool registry — register tools enabled in config
roomCtx := &toolmemory.RoomContext{}
toolReg := buildToolRegistry(cfg, sshExec, matrixClient, memInit.store, deps.kStore, deps.sharedKStore, deps.mcpManager, deps.skillLoader, deps.skillExecutor, roomCtx, logger)
// Rate limiting for tools
initRateLimiter(cfg, toolReg, logger)
a := &Agent{
cfg: cfg,
acl: agentACL,
personality: personality.FromConfig(cfg.Personality),
rules: rules,
llm: llmFunc,
matrix: matrixClient,
sender: matrixClient,
runner: runner,
toolReg: toolReg,
logger: logger,
cryptoStore: cryptoStore,
mcpManager: deps.mcpManager,
done: make(chan struct{}),
commands: make(map[string]CommandHandler),
cmdAliases: command.BuiltinNames(),
startTime: time.Now(),
windows: make(map[string]memory.Window),
memStore: memInit.store,
knowledgeStore: deps.kStore,
sharedKnowledgeStore: deps.sharedKStore,
skillLoader: deps.skillLoader,
windowSize: memInit.windowSize,
roomCtx: roomCtx,
}
// Apply optional configuration
for _, opt := range opts {
opt(a)
}
// Initialize audit writer if enabled
if cfg.Security.Audit.Enabled {
var matrixSender audit.MatrixSender
if cfg.Security.Audit.LogToRoom != "" {
mc := matrixClient // capture for closure
matrixSender = func(roomID, msg string) {
if err := mc.SendMarkdown(context.Background(), roomID, msg); err != nil {
logger.Warn("audit_matrix_send_error", "room", roomID, "err", err)
}
}
}
aw, auditErr := audit.New(cfg.Security.Audit, matrixSender, logger)
if auditErr != nil {
logger.Error("audit_writer_init_failed", "err", auditErr)
} else {
a.auditWriter = aw
logger.Info("audit trail enabled",
"log_file", cfg.Security.Audit.LogFile,
"log_to_room", cfg.Security.Audit.LogToRoom,
"include", cfg.Security.Audit.Include,
)
// Wire tool_exec audit into the tool registry
agentID := cfg.Agent.ID
toolReg.SetAuditFunc(func(toolName string, durationMS int64, toolErr error) {
detail := fmt.Sprintf("tool=%s duration_ms=%d", toolName, durationMS)
if toolErr != nil {
detail += " error=" + toolErr.Error()
}
a.emitAudit(audit.Event{
AgentID: agentID,
EventType: audit.EventToolExec,
Detail: detail,
})
})
}
}
// Configure sanitization if enabled
if cfg.Security.Sanitize.Enabled {
minSev := parseSeverity(cfg.Security.Sanitize.MinSeverity)
a.sanitizeOpts = &sanitize.Options{
Mode: sanitize.ParseMode(cfg.Security.Sanitize.Mode),
MinSeverity: minSev,
DisabledPatterns: cfg.Security.Sanitize.DisabledPatterns,
}
logger.Info("input sanitization enabled",
"mode", a.sanitizeOpts.Mode,
"min_severity", minSev,
)
}
// 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 && memInit.store != nil {
toolReg.Register(toolmemory.NewMemoryClearContext(a, roomCtx))
}
// Cron scheduler — only when schedules are configured
if len(cfg.Schedules) > 0 {
a.scheduler = shellcron.New(cfg.Schedules, matrixClient, llmFunc, cfg.LLM.Primary.Model, logger)
logger.Info("cron scheduler configured", "schedules", len(cfg.Schedules))
}
// Matrix event listener
a.listener = matrix.NewListener(matrixClient, cfg.Matrix, a.handleEvent, logger)
return a, nil
}
// initCrypto initializes E2EE if enabled and returns the crypto store closer.
func initCrypto(cfg *config.AgentConfig, matrixClient *matrix.Client, logger *slog.Logger) (io.Closer, error) {
if !cfg.Matrix.Encryption.Enabled {
return nil, nil
}
storePath := filepath.Join(cfg.Matrix.Encryption.StorePath, "crypto.db")
pickleKey := os.Getenv(cfg.Matrix.Encryption.PickleKeyEnv)
logger.Info("initializing e2ee", "store", storePath)
cryptoStore, err := matrixClient.InitCrypto(context.Background(), storePath, pickleKey, cfg.Agent.ID)
if err != nil {
return nil, fmt.Errorf("e2ee init: %w", err)
}
// Auto-fetch cross-signing private keys from SSSS if recovery key is configured.
if envName := cfg.Matrix.Encryption.RecoveryKeyEnv; envName != "" {
if rk := os.Getenv(envName); rk != "" {
if err := matrixClient.FetchCrossSigningKeys(context.Background(), rk); err != nil {
logger.Warn("failed to fetch cross-signing keys from SSSS (non-fatal)", "err", err)
} else {
logger.Info("cross-signing private keys fetched from SSSS")
}
}
}
// Sign own device with the self-signing key so Element shows it as verified.
if err := matrixClient.SignOwnDevice(context.Background()); err != nil {
logger.Warn("failed to sign own device (non-fatal)", "err", err)
} else {
logger.Info("own device signed with cross-signing key")
}
logger.Info("e2ee ready")
return cryptoStore, nil
}
// RegisterCommand adds a custom command handler for this agent.
// The spec provides metadata (aliases, description, usage) for !help.
// Must be called before Run().
func (a *Agent) RegisterCommand(spec command.Spec, handler CommandHandler) {
a.commands[spec.Name] = handler
a.cmdAliases[spec.Name] = spec.Name
for _, alias := range spec.Aliases {
a.cmdAliases[alias] = spec.Name
}
a.customSpecs = append(a.customSpecs, spec)
a.logger.Info("command_registered", "command", spec.Name, "aliases", spec.Aliases)
}
// SetBus attaches the agent to the inter-agent bus for orchestration.
// Must be called before Run().
func (a *Agent) SetBus(b *bus.Bus) {
a.agentBus = b
}
// SetInterceptor configures the listener to skip events in orchestrated rooms.
func (a *Agent) SetInterceptor(fn matrix.InterceptFunc) {
a.listener.SetInterceptor(fn)
}
// SetMembershipNotify registers a callback for room membership changes.
func (a *Agent) SetMembershipNotify(fn matrix.MembershipNotifyFunc) {
a.listener.SetMembershipNotify(fn)
}
// RawMatrixClient returns the underlying *mautrix.Client for room scanning.
func (a *Agent) RawMatrixClient() *mautrix.Client {
return a.matrix.Raw()
}
// Stop cancels this agent's individual context, causing Run to return.
// Safe to call multiple times.
func (a *Agent) Stop() {
if a.cancel != nil {
a.cancel()
}
}
// Done returns a channel that is closed when Run has returned.
func (a *Agent) Done() <-chan struct{} {
return a.done
}
// Run starts the agent sync loop. Blocks until ctx is cancelled.
func (a *Agent) Run(ctx context.Context) error {
ctx, a.cancel = context.WithCancel(ctx)
defer close(a.done)
if a.cryptoStore != nil {
defer a.cryptoStore.Close()
}
if a.memStore != nil {
defer a.memStore.Close()
}
if a.knowledgeStore != nil {
defer a.knowledgeStore.Close()
}
if a.sharedKnowledgeStore != nil {
defer a.sharedKnowledgeStore.Close()
}
if a.mcpManager != nil {
defer a.mcpManager.Close()
}
if a.auditWriter != nil {
defer a.auditWriter.Close()
}
a.logger.Info("agent starting",
"id", a.cfg.Agent.ID,
"name", a.cfg.Agent.Name,
"tools", a.toolReg.Names(),
)
// Set presence to online
if err := a.matrix.SetPresence(ctx, event.PresenceOnline); err != nil {
a.logger.Warn("failed to set presence online", "err", err)
}
defer func() {
// Use background context since ctx is already cancelled at shutdown
offlineCtx := context.Background()
if err := a.matrix.SetPresence(offlineCtx, event.PresenceOffline); err != nil {
a.logger.Warn("failed to set presence offline", "err", err)
}
}()
// Start bus listener if connected to the orchestration bus
if a.agentBus != nil {
ch := a.agentBus.Subscribe(bus.AgentID(a.cfg.Agent.ID))
go a.listenBus(ctx, ch)
a.logger.Info("bus listener started")
}
// Start cron scheduler in background goroutine (blocks until ctx cancelled)
if a.scheduler != nil {
go a.scheduler.Start(ctx)
}
return a.listener.Run(ctx)
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
package agents
import (
"context"
"github.com/enmanuel/agents/pkg/command"
)
// Runner is the common interface that both Agent and Robot satisfy.
// The launcher uses this to manage agents and robots uniformly.
type Runner interface {
// Run starts the Matrix sync loop. Blocks until ctx is cancelled.
Run(ctx context.Context) error
// Stop cancels the runner's internal context, causing Run to return.
Stop()
// Done returns a channel closed when Run has returned.
Done() <-chan struct{}
// RegisterCommand adds a custom command handler.
RegisterCommand(spec command.Spec, handler CommandHandler)
}