Se mueve la documentación de issues/tasks de .claude/tasks/ a dev/issues/ para separar la planificación de desarrollo de la configuración de Claude. Se añade dev/README.md como índice de la carpeta de desarrollo. Los issues completados se mueven a dev/issues/completed/. Esto permite que dev/ sea el punto central de documentación interna del proyecto. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
9.8 KiB
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:
{
"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:
{
"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
// 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:
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:
- Adquirir lock.
- Verificar si el día cambió (
time.Now().Format("2006-01-02")vsw.currentDay). - Si cambió el día: cerrar archivo actual, comprimir si
compress=true, abrir nuevo archivo del día, resetearwrittenysuffix. - Si
written > maxSizeMB * 1024 * 1024: incrementarsuffix, abrir nuevo archivo (2026-03-06.1.jsonl), resetearwritten. - Escribir
pal archivo actual. - Incrementar
written.
6. Helpers para Consulta por LLMs
Proveer funciones utilitarias para que los agentes puedan consultar logs:
// 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/slogcomo base. No dependencias externas excepto lo estrictamente necesario (si lumberjack simplifica, se puede usar, pero la implementación custom delDailyRotatingWriteres 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:
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/slognativo). - 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:
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_idpermite correlacionar un flujo completo a través de múltiples agentes. Si el orchestrator inicia una tarea y delega al researcher, ambos usan el mismotrace_id. - Considerar un helper
WithTraceID(ctx, id)/TraceIDFromCtx(ctx)usandocontext.Value. - El campo
reasoncaptura 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.