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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user