# Tarea: Implementar Sistema de Logging Estructurado para Agentes ## Contexto del Proyecto Estamos construyendo un sistema multi-agente en Go con las siguientes características arquitectónicas: - **Separación pure core / impure shell**: el core retorna decisiones como datos, el shell las ejecuta e interactúa con el mundo exterior. - **Monorepo en Go** con módulos separados. - **Comunicación inter-agente via Matrix** (mautrix-go) como bus de mensajes. - **Múltiples agentes** con identidades independientes (cada uno con su propio contexto Git, etc.). - **Integración con múltiples LLM providers** (Anthropic, OpenAI-compatible, Ollama) via abstracción unificada. El logging vive en el **impure shell** — nunca en el core. ## Objetivo Crear un paquete `pkg/logger` (o `internal/logger`) que provea logging estructurado en formato JSONL, optimizado para ser consumido tanto por humanos como por agentes LLM. Los logs deben ser fácilmente parseables, consultables por fecha/agente, y auto-gestionados (rotación, limpieza). ## Requisitos Funcionales ### 1. Formato de Salida: JSONL Cada línea de log es un objeto JSON independiente con los siguientes campos obligatorios: ```json { "time": "2026-03-06T10:00:00.000Z", "level": "INFO", "msg": "agent action completed", "agent_id": "researcher-01", "trace_id": "abc123", "component": "shell" } ``` Campos opcionales según contexto: ```json { "action": "web_search", "duration_ms": 342, "tokens_used": 1500, "result": "success", "error_type": "timeout", "reason": "user requested summary of recent papers", "metadata": {} } ``` El campo `reason` es especialmente importante: cuando otro agente lee el log, necesita saber *por qué* se tomó una decisión, no solo *qué* se hizo. ### 2. Segmentación de Archivos Estructura de directorios por agente y por día: ``` /var/log/agents/ ├── orchestrator/ │ ├── 2026-03-04.jsonl │ ├── 2026-03-05.jsonl │ └── 2026-03-06.jsonl ├── researcher-01/ │ ├── 2026-03-05.jsonl │ └── 2026-03-06.jsonl └── coder-01/ └── 2026-03-06.jsonl ``` Reglas: - Un archivo JSONL por agente por día. - Si un archivo excede un tamaño máximo configurable (default: 50MB), se rota añadiendo un sufijo incremental: `2026-03-06.jsonl` → `2026-03-06.1.jsonl`. - Nombres de archivo siempre en formato `YYYY-MM-DD.jsonl`. ### 3. Rotación y Limpieza - **Retención configurable** (default: 7 días). - **Goroutine de limpieza** que corre periódicamente (default: cada 24h) y elimina archivos que excedan la retención. - **Compresión opcional** de archivos rotados (gzip). - La limpieza debe ser segura para ejecución concurrente. ### 4. API del Logger ```go // Config para crear un logger de agente type LoggerConfig struct { BaseDir string // directorio raíz de logs (default: "/var/log/agents") AgentID string // identificador único del agente MaxSizeMB int64 // tamaño máximo por archivo (default: 50) MaxAgeDays int // días de retención (default: 7) Compress bool // comprimir archivos rotados (default: true) CleanupInterval time.Duration // intervalo de limpieza (default: 24h) Level slog.Level // nivel mínimo de log (default: slog.LevelInfo) } // Factory function func NewAgentLogger(cfg LoggerConfig) (*slog.Logger, func(), error) // Retorna: // - *slog.Logger: logger configurado con slog // - func(): función de cleanup para llamar en shutdown (cierra archivos, detiene goroutine de limpieza) // - error: si no se puede crear el directorio o el archivo inicial // Uso esperado: logger, cleanup, err := logger.NewAgentLogger(logger.LoggerConfig{ AgentID: "researcher-01", }) defer cleanup() logger.InfoContext(ctx, "executing decision", "action", decision.Action, "reason", decision.Reason, "trace_id", traceIDFromCtx(ctx), "tokens_used", 1500, ) ``` ### 5. Writer Personalizado Implementar un `io.Writer` que maneje la rotación diaria con fallback por tamaño: ```go type DailyRotatingWriter struct { baseDir string agentID string maxSizeMB int64 compress bool mu sync.Mutex current *os.File written int64 currentDay string suffix int // para rotación por tamaño dentro del mismo día } // Debe implementar io.Writer func (w *DailyRotatingWriter) Write(p []byte) (n int, err error) // Cierre limpio func (w *DailyRotatingWriter) Close() error ``` Lógica de `Write`: 1. Adquirir lock. 2. Verificar si el día cambió (`time.Now().Format("2006-01-02")` vs `w.currentDay`). 3. Si cambió el día: cerrar archivo actual, comprimir si `compress=true`, abrir nuevo archivo del día, resetear `written` y `suffix`. 4. Si `written > maxSizeMB * 1024 * 1024`: incrementar `suffix`, abrir nuevo archivo (`2026-03-06.1.jsonl`), resetear `written`. 5. Escribir `p` al archivo actual. 6. Incrementar `written`. ### 6. Helpers para Consulta por LLMs Proveer funciones utilitarias para que los agentes puedan consultar logs: ```go // Leer logs de un agente en un rango de fechas func ReadLogs(baseDir, agentID string, from, to time.Time) ([]json.RawMessage, error) // Leer logs de un agente para un día específico func ReadDayLogs(baseDir, agentID string, date time.Time) ([]json.RawMessage, error) // Buscar logs que contengan un campo con un valor específico func SearchLogs(baseDir, agentID string, field, value string, from, to time.Time) ([]json.RawMessage, error) // Listar agentes disponibles (subdirectorios) func ListAgents(baseDir string) ([]string, error) // Listar fechas disponibles para un agente func ListDates(baseDir, agentID string) ([]time.Time, error) ``` Estas funciones permiten que un agente LLM solicite logs con interfaces simples. El agente orquestador puede usar `SearchLogs` para buscar errores, o `ReadDayLogs` para obtener contexto de lo que hizo otro agente ayer. ## Requisitos No Funcionales - **Stdlib primero**: usar `log/slog` como base. No dependencias externas excepto lo estrictamente necesario (si lumberjack simplifica, se puede usar, pero la implementación custom del `DailyRotatingWriter` es preferida). - **Thread-safe**: múltiples goroutines escribirán al mismo logger. - **Mínimo overhead**: el logging no debe impactar significativamente el rendimiento del agente. Escribir en buffer si es necesario. - **Consistencia de campos**: usar los mismos nombres de campo siempre. Definir constantes para campos estándar: ```go const ( FieldAgentID = "agent_id" FieldTraceID = "trace_id" FieldAction = "action" FieldReason = "reason" FieldDurationMS = "duration_ms" FieldTokensUsed = "tokens_used" FieldResult = "result" FieldErrorType = "error_type" FieldComponent = "component" ) ``` - **Testeable**: incluir tests unitarios para: - Rotación por día. - Rotación por tamaño dentro del mismo día. - Limpieza de archivos viejos. - Formato de salida JSONL correcto. - Concurrencia (múltiples writers simultáneos). - Funciones de consulta (`ReadLogs`, `SearchLogs`). ## Estructura de Archivos Esperada ``` pkg/logger/ ├── logger.go // NewAgentLogger, LoggerConfig, constantes de campos ├── writer.go // DailyRotatingWriter implementation ├── cleanup.go // Goroutine de limpieza y compresión ├── query.go // ReadLogs, SearchLogs, ListAgents, ListDates ├── logger_test.go // Tests del logger y formato ├── writer_test.go // Tests de rotación ├── cleanup_test.go // Tests de limpieza └── query_test.go // Tests de consulta ``` ## Restricciones - Go 1.21+ (para `log/slog` nativo). - Sin CGO. - Sin dependencias externas (stdlib pura). Si consideras que alguna dependencia aporta valor significativo, justifícala explícitamente. - El logger debe poder funcionar tanto escribiendo a archivos como a stdout (para desarrollo/debugging), configurable via `LoggerConfig`. - Todos los timestamps en UTC. ## Ejemplo de Integración Así se vería el uso del logger dentro del shell de un agente: ```go package main import ( "context" "log/slog" "myproject/pkg/logger" ) func main() { log, cleanup, err := logger.NewAgentLogger(logger.LoggerConfig{ AgentID: "researcher-01", BaseDir: "/var/log/agents", Level: slog.LevelInfo, Compress: true, }) if err != nil { panic(err) } defer cleanup() ctx := context.Background() ctx = logger.WithTraceID(ctx, "trace-abc-123") // El core retorna una decisión pura decision := core.Decide(input) // El shell loguea y ejecuta log.InfoContext(ctx, "executing decision", logger.FieldAction, decision.Action, logger.FieldReason, decision.Reason, logger.FieldComponent, "shell", ) result, err := shell.Execute(ctx, decision) if err != nil { log.ErrorContext(ctx, "decision execution failed", logger.FieldAction, decision.Action, logger.FieldErrorType, categorizeError(err), "error", err.Error(), ) return } log.InfoContext(ctx, "decision executed successfully", logger.FieldAction, decision.Action, logger.FieldResult, "success", logger.FieldDurationMS, result.DurationMS, logger.FieldTokensUsed, result.TokensUsed, ) } ``` ## Notas Adicionales - El `trace_id` permite correlacionar un flujo completo a través de múltiples agentes. Si el orchestrator inicia una tarea y delega al researcher, ambos usan el mismo `trace_id`. - Considerar un helper `WithTraceID(ctx, id)` / `TraceIDFromCtx(ctx)` usando `context.Value`. - El campo `reason` captura la intención detrás de la acción. Un LLM que lee "reason: user requested summary of recent AI papers" entiende el contexto sin necesidad de reconstruirlo desde mensajes anteriores.