feat: anadir comando !metrics para metricas agregadas del dia actual

Implementa el comando built-in !metrics que lee los JSONL logs del dia
actual usando shell/logger/query.go y calcula agregados en memoria:

- Mensajes recibidos (handling event)
- Comandos ejecutados (command_received)
- Llamadas LLM (count, tokens totales, latencia media)
- Llamadas a tools (count, errores)
- Errores totales (nivel ERROR)
- Total de entradas de log

El comando se registra como built-in disponible para todos los agentes.
Recibe logDir via Option pattern (WithLogDir) para no romper la firma
de agents.New(). El launcher pasa logDir al crear cada agente.

Formatea la salida como tabla markdown para Matrix.
Incluye tests unitarios con logs JSONL sinteticos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:13:34 +00:00
parent fb96a79feb
commit 05668e398f
3 changed files with 241 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
package agents
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/decision"
"github.com/enmanuel/agents/tools"
"log/slog"
)
// newTestAgent creates a minimal Agent for testing commands.
// Does NOT connect to Matrix or LLM.
func newTestAgent(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 := newTestAgent("")
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 := newTestAgent(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 := newTestAgent(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 := newTestAgent(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)
}
}