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
+107
View File
@@ -2,12 +2,14 @@ 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.
@@ -21,6 +23,7 @@ func (a *Agent) registerBuiltinCommands() {
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).
@@ -287,6 +290,110 @@ func (a *Agent) cmdVersion(_ context.Context, _ decision.MessageContext) string
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))
+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)
}
}
+5
View File
@@ -50,6 +50,11 @@ func Builtins() []Spec {
Description: "Version del agente",
Usage: "!version",
},
{
Name: "metrics",
Description: "Metricas agregadas del dia actual",
Usage: "!metrics",
},
}
}