05668e398f
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>
405 lines
11 KiB
Go
405 lines
11 KiB
Go
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
|
|
}
|