diff --git a/agents/commands.go b/agents/commands.go index bd3ab5d..12e0fb6 100644 --- a/agents/commands.go +++ b/agents/commands.go @@ -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)) diff --git a/agents/commands_metrics_test.go b/agents/commands_metrics_test.go new file mode 100644 index 0000000..7e4dace --- /dev/null +++ b/agents/commands_metrics_test.go @@ -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) + } +} diff --git a/pkg/command/builtins.go b/pkg/command/builtins.go index d38c4fd..6a4ff5f 100644 --- a/pkg/command/builtins.go +++ b/pkg/command/builtins.go @@ -50,6 +50,11 @@ func Builtins() []Spec { Description: "Version del agente", Usage: "!version", }, + { + Name: "metrics", + Description: "Metricas agregadas del dia actual", + Usage: "!metrics", + }, } }