refactor: migrar tasks/ a dev/issues/ con estructura de desarrollo
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>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# Plan: Herramientas para los bots
|
||||
|
||||
## Objetivo
|
||||
Permitir que los bots ejecuten herramientas reales (funciones Go) como respuesta a
|
||||
decisiones del LLM — patrón function calling / tool use.
|
||||
|
||||
## Estado: COMPLETADO
|
||||
|
||||
---
|
||||
|
||||
## Diseño
|
||||
|
||||
### Capa pura (`pkg/tools/`)
|
||||
- Definir `ToolSpec` con nombre, descripción y esquema JSON de parámetros
|
||||
- Definir `ToolCallAction` en `pkg/decision/` — acción pura que contiene
|
||||
`ToolName string` y `Args map[string]any`
|
||||
- El motor de reglas puede emitir `ToolCallAction` como cualquier otra acción
|
||||
|
||||
### Capa shell (`shell/tools/`)
|
||||
- `Executor` que mapea nombre → función Go real
|
||||
- Ejecuta la herramienta y devuelve `ToolResult{Output string, Err error}`
|
||||
- El Runner de `shell/effects/` llama al Executor cuando recibe `ToolCallAction`
|
||||
|
||||
### Integración LLM
|
||||
- `shell/llm/anthropic.go` y `openai.go` ya soportan tool_use / function_calling
|
||||
- Mapear `[]ToolSpec` al formato nativo de cada proveedor
|
||||
- Parsear la respuesta del LLM para extraer llamadas a herramientas
|
||||
|
||||
### Herramientas iniciales a implementar
|
||||
| Herramienta | Descripción | Shell |
|
||||
|-----------------|-------------------------------------|-------------------|
|
||||
| `http_get` | GET a una URL, devuelve body | `shell/tools/` |
|
||||
| `http_post` | POST JSON a una URL | `shell/tools/` |
|
||||
| `ssh_command` | Ejecutar comando remoto por SSH | `shell/ssh/` |
|
||||
| `read_file` | Leer archivo local | `shell/tools/` |
|
||||
| `matrix_send` | Enviar mensaje a una sala Matrix | `shell/matrix/` |
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
- `pkg/tools/spec.go` — ToolSpec, ToolResult
|
||||
- `pkg/decision/actions.go` — añadir ToolCallAction
|
||||
- `shell/tools/executor.go` — registro y ejecución de herramientas
|
||||
- `shell/effects/runner.go` — manejar ToolCallAction
|
||||
- `shell/llm/anthropic.go` — emitir tools en el request, parsear tool_use blocks
|
||||
- `shell/llm/openai.go` — idem para function_calling
|
||||
- `agents/<id>/agent.go` — registrar herramientas por agente
|
||||
|
||||
## Notas
|
||||
- Las herramientas se declaran en `pkg/` (pure spec) pero se implementan en `shell/`
|
||||
- Un agente solo tiene acceso a las herramientas declaradas en su config
|
||||
- Respetar `security.allowed_tools` del config YAML
|
||||
@@ -0,0 +1,95 @@
|
||||
# Plan: Memoria para los bots
|
||||
|
||||
## Objetivo
|
||||
Que cada bot recuerde conversaciones anteriores, hechos importantes sobre usuarios
|
||||
y contexto de salas. Memoria a corto plazo (ventana de conversación) y largo plazo
|
||||
(SQLite persistente).
|
||||
|
||||
## Estado: completado ✓
|
||||
|
||||
---
|
||||
|
||||
## Tipos de memoria
|
||||
|
||||
### 1. Memoria de conversación (corto plazo)
|
||||
- Ventana deslizante de `N` mensajes por room
|
||||
- Se pasa como historial al LLM en cada llamada
|
||||
- Vive en RAM; se pierde al reiniciar (aceptable)
|
||||
|
||||
### 2. Memoria episódica (largo plazo)
|
||||
- Hechos extraídos de conversaciones: nombre del usuario, preferencias, eventos
|
||||
- Guardados en SQLite (`agents/<id>/data/memory.db`)
|
||||
- El LLM puede leer y escribir hechos mediante herramientas (`remember`, `recall`)
|
||||
|
||||
---
|
||||
|
||||
## Diseño capa pura (`pkg/memory/`)
|
||||
|
||||
```go
|
||||
// Tipos puros — sin I/O
|
||||
type Message struct {
|
||||
Role string // "user" | "assistant"
|
||||
Content string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type Fact struct {
|
||||
Subject string
|
||||
Key string
|
||||
Value string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Ventana de conversación
|
||||
type Window struct {
|
||||
RoomID string
|
||||
Messages []Message
|
||||
MaxSize int
|
||||
}
|
||||
|
||||
func (w Window) Append(m Message) Window { ... } // pura
|
||||
func (w Window) ToLLMMessages() []llm.Message { ... } // pura
|
||||
```
|
||||
|
||||
## Diseño capa shell (`shell/memory/`)
|
||||
|
||||
```go
|
||||
// Acceso a SQLite — impuro
|
||||
type Store interface {
|
||||
SaveFact(ctx, agentID, fact) error
|
||||
GetFacts(ctx, agentID, subject) ([]Fact, error)
|
||||
GetHistory(ctx, agentID, roomID, limit) ([]Message, error)
|
||||
AppendMessage(ctx, agentID, roomID, msg) error
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Herramientas LLM para memoria
|
||||
- `remember(subject, key, value)` — guardar un hecho
|
||||
- `recall(subject, key)` — recuperar hechos sobre alguien/algo
|
||||
- `forget(subject, key)` — borrar un hecho
|
||||
|
||||
---
|
||||
|
||||
## Integración con el flujo actual
|
||||
1. `agents/runtime.go` mantiene un `map[roomID]memory.Window` en RAM
|
||||
2. Antes de llamar al LLM, inyectar historial de la ventana al request
|
||||
3. Después de la respuesta, hacer `Append` con el mensaje del bot
|
||||
4. Las herramientas `remember`/`recall` van al `Store` SQLite
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
- `pkg/memory/types.go` — Message, Fact, Window (puros)
|
||||
- `pkg/memory/window.go` — operaciones sobre Window (puras)
|
||||
- `shell/memory/sqlite_store.go` — Store SQLite
|
||||
- `shell/memory/migrations/001_init.sql` — schema
|
||||
- `agents/runtime.go` — inyectar historial antes del LLM call
|
||||
- `agents/<id>/agent.go` — registrar herramientas remember/recall
|
||||
|
||||
## Notas
|
||||
- Schema SQLite: tabla `facts(agent_id, subject, key, value, updated_at)`,
|
||||
tabla `messages(agent_id, room_id, role, content, created_at)`
|
||||
- El tamaño de la ventana se configura en `storage.max_context_messages`
|
||||
(añadir al schema de config)
|
||||
@@ -0,0 +1,275 @@
|
||||
# Plan: Multi-bot Orchestration — Middleware invisible
|
||||
|
||||
## Objetivo
|
||||
Cuando hay más de un bot en una sala, un **orquestador invisible** (sin identidad
|
||||
Matrix) coordina quién responde y cuándo. Opera como middleware en el proceso del
|
||||
launcher — los humanos solo ven a los bots especializados respondiendo.
|
||||
|
||||
## Estado: Completo
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura: `agents/specials/`
|
||||
|
||||
Los **special agents** son componentes de sistema sin identidad Matrix. Viven en
|
||||
`agents/specials/<id>/` y el launcher los instancia de forma diferente a los bots
|
||||
normales: sin token, sin listener propio, sin `user_id`.
|
||||
|
||||
```
|
||||
agents/
|
||||
assistant/ → bot normal (Matrix user, token, listener)
|
||||
specials/ → componentes de sistema, sin identidad Matrix
|
||||
orchestrator/ → middleware de coordinación multi-bot
|
||||
scheduler/ → (futuro) cron runner
|
||||
memory/ → (futuro) gestor de historial cross-bot
|
||||
```
|
||||
|
||||
### Diferencias vs bot normal
|
||||
|
||||
| | Bot normal | Special agent |
|
||||
|---|---|---|
|
||||
| Matrix user | ✓ (@bot:server) | ✗ |
|
||||
| Token propio | ✓ | ✗ |
|
||||
| Listener Matrix | ✓ | ✗ |
|
||||
| LLM propio | opcional | ✓ (para decisiones) |
|
||||
| Instanciado por | launcher vía rulesRegistry | launcher vía specialsRegistry |
|
||||
| Visible en salas | ✓ | ✗ nunca |
|
||||
|
||||
---
|
||||
|
||||
## Config del orquestador
|
||||
|
||||
```yaml
|
||||
# agents/specials/orchestrator/config.yaml
|
||||
|
||||
special:
|
||||
id: orchestrator
|
||||
type: orchestrator # clave para que el launcher sepa cómo instanciarlo
|
||||
enabled: true
|
||||
description: "Middleware de coordinación multi-bot. Sin identidad Matrix."
|
||||
|
||||
llm:
|
||||
primary:
|
||||
provider: anthropic
|
||||
model: claude-sonnet-4-6
|
||||
api_key_env: ANTHROPIC_API_KEY
|
||||
max_tokens: 512 # respuestas cortas: solo IDs de bots y scores
|
||||
temperature: 0.2 # determinista para routing
|
||||
|
||||
orchestration:
|
||||
max_iterations: 3 # máximo de bots que responden por pregunta
|
||||
quality_threshold: 0.8 # score mínimo para cortar el pipeline (0.0–1.0)
|
||||
silent: true # no emite mensajes Matrix propios
|
||||
delegation_timeout: 30s # tiempo máximo esperando respuesta de un bot
|
||||
|
||||
rooms:
|
||||
- room_id: "${MATRIX_ROOM_SHARED}"
|
||||
participants: # bots que participan en esta sala
|
||||
- id: assistant-bot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flujo de eventos
|
||||
|
||||
```
|
||||
Matrix event (room compartida)
|
||||
│
|
||||
▼
|
||||
Launcher (event router)
|
||||
│
|
||||
├─► ¿hay orquestador activo para este room? ──No──► dispatch normal
|
||||
│
|
||||
▼ Sí
|
||||
Orchestrator.Route(event, participants)
|
||||
│
|
||||
│ LLM Call 1: "¿Qué bot responde primero?"
|
||||
▼
|
||||
Bus.Dispatch(taskEvent → bot-A)
|
||||
│
|
||||
▼
|
||||
bot-A.Handle(task) → SendMessage(room, respuesta)
|
||||
│
|
||||
▼
|
||||
Orchestrator.Evaluate(pregunta, respuesta-A)
|
||||
│ LLM Call 2: score + continue?
|
||||
│
|
||||
├─► score >= threshold ──► fin del pipeline
|
||||
│
|
||||
▼ continuar
|
||||
Bus.Dispatch(taskEvent → bot-B) # bot-B ≠ bot-A (exclusión del último)
|
||||
(taskEvent incluye pregunta + respuesta-A como contexto)
|
||||
│
|
||||
▼
|
||||
bot-B.Handle(task) → SendMessage(room, respuesta mejorada)
|
||||
│
|
||||
▼
|
||||
Orchestrator.Evaluate(...) # repite hasta max_iterations o threshold
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protocolo interno: TaskEvent
|
||||
|
||||
El orquestador no usa Matrix para comunicarse con los bots — usa el bus interno
|
||||
(`shell/bus`). Todos los bots corren en el mismo proceso del launcher.
|
||||
|
||||
```go
|
||||
// pkg/orchestration/task.go
|
||||
type TaskEvent struct {
|
||||
TargetBotID string
|
||||
TargetRoomID string
|
||||
OriginalQuestion string
|
||||
Iteration int
|
||||
PreviousResponses []BotResponse // vacío en primera iteración
|
||||
}
|
||||
|
||||
type BotResponse struct {
|
||||
BotID string
|
||||
Text string
|
||||
}
|
||||
|
||||
type QualityScore struct {
|
||||
Score float64 // 0.0–1.0
|
||||
Continue bool
|
||||
Reason string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LLM calls del orquestador
|
||||
|
||||
### Call 1: Routing inicial
|
||||
```
|
||||
System (prompts/routing.md):
|
||||
Eres un coordinador de agentes. Disponibles:
|
||||
- assistant-bot: Asistente general, preguntas, resúmenes, redacción
|
||||
Responde SOLO con el ID del bot más adecuado.
|
||||
|
||||
User: [pregunta del humano]
|
||||
```
|
||||
|
||||
### Call 2: Evaluación de calidad
|
||||
```
|
||||
System (prompts/quality.md):
|
||||
Evalúa si la respuesta resuelve completamente la pregunta.
|
||||
Responde en JSON: {"score": 0.0-1.0, "continue": bool, "reason": "..."}
|
||||
|
||||
User:
|
||||
Pregunta: [...]
|
||||
Respuesta de [bot-X]: [...]
|
||||
```
|
||||
|
||||
### Call 3: Routing de refinamiento (si continue=true)
|
||||
```
|
||||
System:
|
||||
La respuesta necesita mejora. Bots disponibles (excluido [último]):
|
||||
- [lista sin el último respondedor]
|
||||
Responde SOLO con el ID del bot.
|
||||
|
||||
User:
|
||||
Pregunta: [...] | Respuesta actual: [...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportamiento de los bots en sala orquestada
|
||||
|
||||
Los bots **no saben** que están siendo orquestados. El launcher simplemente no
|
||||
les entrega el evento Matrix directamente. En su lugar reciben un `TaskEvent`
|
||||
via bus con el contexto correcto.
|
||||
|
||||
Un bot en sala orquestada responde al `TaskEvent` igual que responde a un
|
||||
mensaje normal: genera texto y llama a `SendMessage(targetRoomID, text)`.
|
||||
La diferencia la gestiona el launcher, no el bot.
|
||||
|
||||
Esto preserva el principio **pure core / impure shell** — los bots siguen siendo
|
||||
puros, el orquestador es shell.
|
||||
|
||||
---
|
||||
|
||||
## Launcher: registro de specials
|
||||
|
||||
```go
|
||||
// cmd/launcher/main.go — nuevo registro análogo a rulesRegistry
|
||||
var specialsRegistry = map[string]special.Factory{
|
||||
"orchestrator": orchestration.New,
|
||||
// "scheduler": scheduler.New, // futuro
|
||||
// "memory": memory.New, // futuro
|
||||
}
|
||||
```
|
||||
|
||||
El launcher escanea `agents/specials/*/config.yaml`, lee el campo `special.type`,
|
||||
busca en `specialsRegistry` y lo instancia. Los specials se arrancan antes que
|
||||
los bots normales (son infraestructura).
|
||||
|
||||
---
|
||||
|
||||
## Anti-bucle: garantías
|
||||
|
||||
| Escenario | Mitigación |
|
||||
|-----------|-----------|
|
||||
| Bot responde sin ser delegado | El launcher no entrega eventos Matrix en salas orquestadas directamente |
|
||||
| Loop de refinamiento infinito | `max_iterations` hard limit |
|
||||
| Orquestador elige el mismo bot dos veces seguidas | Exclusión explícita del último respondedor en Call 3 |
|
||||
| Bot no responde (timeout) | `delegation_timeout` → orquestador corta o elige otro bot |
|
||||
| Sala con 1 solo bot | El orquestador detecta `len(participants)==1` y hace dispatch directo sin LLM |
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear
|
||||
|
||||
```
|
||||
agents/specials/orchestrator/
|
||||
config.yaml → config del orquestador (LLM + rooms)
|
||||
prompts/routing.md → system prompt para routing inicial
|
||||
prompts/quality.md → system prompt para evaluación de calidad
|
||||
prompts/refinement.md → system prompt para routing de refinamiento
|
||||
|
||||
pkg/orchestration/
|
||||
task.go → TaskEvent, BotResponse, QualityScore (tipos puros)
|
||||
protocol.go → serialización/deserialización de TaskEvent
|
||||
|
||||
shell/orchestration/
|
||||
orchestrator.go → Orchestrator struct, Route(), Evaluate()
|
||||
runner.go → loop de coordinación, gestión de timeouts
|
||||
|
||||
internal/config/
|
||||
schema.go → SpecialCfg, OrchestrationCfg (nuevas secciones)
|
||||
loader.go → LoadSpecial() análogo a Load()
|
||||
|
||||
cmd/launcher/
|
||||
main.go → specialsRegistry + arranque de specials
|
||||
specials.go → scanSpecials(), instanciación
|
||||
```
|
||||
|
||||
### Modificados
|
||||
```
|
||||
agents/runtime.go → aceptar TaskEvent además de eventos Matrix
|
||||
shell/bus/bus.go → soporte para TaskEvent routing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fases de implementación
|
||||
|
||||
### Fase 1 — Scaffold + protocolo básico
|
||||
- Estructura `agents/specials/` y scanner en launcher
|
||||
- `pkg/orchestration/task.go` con tipos puros
|
||||
- Dispatch via bus sin LLM (keyword matching simple)
|
||||
- Un bot responde, sin refinamiento
|
||||
|
||||
### Fase 2 — LLM routing
|
||||
- Call 1 y Call 3 con LLM real
|
||||
- Exclusión del último respondedor
|
||||
- `max_iterations` funcional
|
||||
|
||||
### Fase 3 — Quality evaluation
|
||||
- Call 2 con score de calidad
|
||||
- `quality_threshold` para corte automático
|
||||
- Logs de orquestación en `run/orchestrator.log`
|
||||
|
||||
### Fase 4 — Observabilidad
|
||||
- Topic del room refleja estado del pipeline en curso
|
||||
- `"[2/3] bot respondió · evaluando..."` → topic actualizado en tiempo real
|
||||
@@ -0,0 +1,69 @@
|
||||
# Plan: Editar fotos de perfil de los bots
|
||||
|
||||
## Objetivo
|
||||
Poder actualizar el avatar (foto de perfil) y el display name de cada bot en Matrix
|
||||
desde la CLI (`agentctl`) o desde un dev-script.
|
||||
|
||||
## Estado: COMPLETADO
|
||||
|
||||
---
|
||||
|
||||
## Cómo funciona en Matrix
|
||||
- Endpoint: `PUT /_matrix/client/v3/profile/{userId}/avatar_url`
|
||||
- Body: `{ "avatar_url": "mxc://..." }` — URI de contenido subido al Media repo
|
||||
- Para subir una imagen: `POST /_matrix/media/v3/upload` con el body binario
|
||||
y `Content-Type` de la imagen
|
||||
- También se puede cambiar el display name:
|
||||
`PUT /_matrix/client/v3/profile/{userId}/displayname`
|
||||
|
||||
La secuencia es:
|
||||
1. Subir imagen → obtener `mxc://server/mediaID`
|
||||
2. Establecer `avatar_url` en el perfil con esa URI
|
||||
|
||||
---
|
||||
|
||||
## Diseño
|
||||
|
||||
### CLI: `agentctl avatar <agent-id> <image-path>`
|
||||
Nuevo subcomando en `cmd/agentctl/`:
|
||||
```
|
||||
agentctl avatar assistant-bot /path/to/photo.png
|
||||
agentctl displayname assistant-bot "Assistant Bot"
|
||||
```
|
||||
|
||||
### Shell: `shell/matrix/profile.go`
|
||||
```go
|
||||
// UploadMedia sube un archivo y devuelve la mxc:// URI
|
||||
func UploadMedia(ctx, client, filePath string) (mxcURI string, err error)
|
||||
|
||||
// SetAvatar establece avatar_url en el perfil del bot
|
||||
func SetAvatar(ctx, client, mxcURI string) error
|
||||
|
||||
// SetDisplayName cambia el displayname
|
||||
func SetDisplayName(ctx, client, name string) error
|
||||
```
|
||||
|
||||
Usa el cliente `mautrix.Client` ya existente en `shell/matrix/client.go`.
|
||||
|
||||
### Dev-script: `dev-scripts/avatar.sh`
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Uso: ./dev-scripts/avatar.sh <agent-id> <image-path>
|
||||
./bin/agentctl avatar "$1" "$2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
- `shell/matrix/profile.go` — UploadMedia, SetAvatar, SetDisplayName
|
||||
- `cmd/agentctl/avatar.go` — subcomando `avatar` y `displayname`
|
||||
- `cmd/agentctl/main.go` — registrar los nuevos subcomandos en Cobra
|
||||
- `dev-scripts/avatar.sh` — wrapper convenience
|
||||
|
||||
## Notas
|
||||
- El token del bot necesita permiso de escritura en su propio perfil (normal por defecto)
|
||||
- Formatos soportados: PNG, JPG, WebP — Matrix los acepta todos
|
||||
- mautrix-go tiene métodos `client.UploadMedia()` y `client.SetAvatarURL()`;
|
||||
usar esos directamente para evitar HTTP manual
|
||||
- El comando debe cargar el token del bot desde las env vars (`MATRIX_TOKEN_<BOT>`)
|
||||
igual que hace `cmd/launcher/`
|
||||
@@ -0,0 +1,317 @@
|
||||
# Plan: Claude Code (`claude -p`) como proveedor LLM de la shell
|
||||
|
||||
## Objetivo
|
||||
|
||||
Que `claude -p` sea un backend LLM más dentro de `shell/llm/`, al mismo nivel que la API HTTP de Anthropic u otros proveedores. Los agentes no saben si su "modelo" es una llamada REST o un subproceso de Claude Code — simplemente envían un `CompletionRequest` y reciben un `CompletionResult`.
|
||||
|
||||
## Estado: Completado
|
||||
|
||||
---
|
||||
|
||||
## Casos de uso
|
||||
|
||||
- Configurar un agente con `model: claude-code` y que todas sus respuestas pasen por `claude -p`
|
||||
- Un agente usa Claude Code como modelo principal, obteniendo capacidades agenticas (bash, file I/O, git) gratis sin implementarlas en nuestra shell
|
||||
- Agentes que necesitan razonar sobre un repo completo delegan al modelo `claude-code` que ya tiene contexto del worktree
|
||||
- Migrar agentes entre proveedores cambiando solo el campo `model` en YAML
|
||||
- Combinar modelos: un agente usa `sonnet` para respuestas rápidas y `claude-code` para tareas que requieren ejecución
|
||||
|
||||
---
|
||||
|
||||
## Diseño
|
||||
|
||||
### Config YAML — el agente simplemente elige su modelo
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
- name: "dev-bot"
|
||||
model: "claude-code" # ← usa claude -p como backend LLM
|
||||
model_config:
|
||||
binary: "claude" # path al binario (default: "claude")
|
||||
max_turns: 10 # turnos agenticos internos de claude -p
|
||||
timeout: "5m"
|
||||
allowed_tools: # tools que claude -p puede usar internamente
|
||||
- "bash"
|
||||
- "read_file"
|
||||
- "write_file"
|
||||
- "git"
|
||||
working_dir: "{{worktree}}"
|
||||
system_prompt_file: "prompts/dev-bot-system.md"
|
||||
|
||||
- name: "chat-bot"
|
||||
model: "sonnet" # ← usa API HTTP normal
|
||||
model_config:
|
||||
api_key_env: "ANTHROPIC_API_KEY"
|
||||
```
|
||||
|
||||
El campo `model` determina qué proveedor de `shell/llm/` se instancia. La `model_config` es específica de cada proveedor.
|
||||
|
||||
---
|
||||
|
||||
### Interfaz pura (core) — sin cambios
|
||||
|
||||
La interfaz del core no cambia. El contrato ya existe:
|
||||
|
||||
```go
|
||||
// core/llm/types.go — esto ya existe o debería existir
|
||||
|
||||
type CompletionRequest struct {
|
||||
SystemPrompt string
|
||||
Messages []Message
|
||||
Temperature float64
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
type CompletionResult struct {
|
||||
Content string
|
||||
TokensUsed TokenUsage
|
||||
FinishReason string // "stop", "max_turns", "timeout", "error"
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type TokenUsage struct {
|
||||
Input int
|
||||
Output int
|
||||
}
|
||||
```
|
||||
|
||||
El core solo conoce esta interfaz. No sabe si detrás hay HTTP, un subproceso o una paloma mensajera.
|
||||
|
||||
---
|
||||
|
||||
### Shell — interfaz `Provider` y registro de proveedores
|
||||
|
||||
```go
|
||||
// shell/llm/provider.go
|
||||
|
||||
type Provider interface {
|
||||
Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Registry mapea nombres de modelo a constructores de Provider
|
||||
type Registry struct {
|
||||
factories map[string]Factory
|
||||
}
|
||||
|
||||
type Factory func(cfg map[string]any, logger *slog.Logger) (Provider, error)
|
||||
|
||||
func (r *Registry) Register(name string, f Factory)
|
||||
func (r *Registry) Build(name string, cfg map[string]any, logger *slog.Logger) (Provider, error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Shell — proveedor HTTP (el que ya existe o existiría)
|
||||
|
||||
```go
|
||||
// shell/llm/anthropic/provider.go
|
||||
|
||||
type AnthropicProvider struct {
|
||||
client *http.Client
|
||||
apiKey string
|
||||
model string // "claude-sonnet-4-20250514", etc.
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewAnthropicProvider(cfg map[string]any, logger *slog.Logger) (llm.Provider, error)
|
||||
|
||||
func (p *AnthropicProvider) Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) {
|
||||
// Construir JSON → POST /v1/messages → parsear respuesta
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Shell — proveedor Claude Code (el nuevo)
|
||||
|
||||
```go
|
||||
// shell/llm/claudecode/provider.go
|
||||
|
||||
type ClaudeCodeProvider struct {
|
||||
binary string
|
||||
maxTurns int
|
||||
timeout time.Duration
|
||||
allowedTools []string
|
||||
workingDir string
|
||||
systemPrompt string // contenido leído del archivo en construcción
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewClaudeCodeProvider(cfg map[string]any, logger *slog.Logger) (llm.Provider, error)
|
||||
|
||||
func (p *ClaudeCodeProvider) Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) {
|
||||
// 1. Construir el prompt final: system prompt del provider + messages del request
|
||||
// 2. Armar los args de claude -p
|
||||
// 3. Ejecutar subproceso
|
||||
// 4. Parsear JSON de salida
|
||||
// 5. Mapear a CompletionResult
|
||||
}
|
||||
```
|
||||
|
||||
#### Construcción del comando (interno del provider)
|
||||
|
||||
```go
|
||||
func (p *ClaudeCodeProvider) buildArgs() []string {
|
||||
args := []string{"-p", "--output-format", "json"}
|
||||
|
||||
if p.maxTurns > 0 {
|
||||
args = append(args, "--max-turns", strconv.Itoa(p.maxTurns))
|
||||
}
|
||||
if len(p.allowedTools) > 0 {
|
||||
args = append(args, "--allowedTools", strings.Join(p.allowedTools, ","))
|
||||
}
|
||||
if p.systemPrompt != "" {
|
||||
args = append(args, "--system-prompt", p.systemPrompt)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func (p *ClaudeCodeProvider) Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, p.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Aplanar messages a un solo prompt para stdin
|
||||
prompt := flattenMessages(req.Messages)
|
||||
|
||||
cmd := exec.CommandContext(ctx, p.binary, p.buildArgs()...)
|
||||
cmd.Dir = p.workingDir
|
||||
cmd.Stdin = strings.NewReader(prompt)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
return p.parseOutput(stdout.Bytes(), stderr.Bytes(), err)
|
||||
}
|
||||
```
|
||||
|
||||
#### Parseo de la salida JSON
|
||||
|
||||
```go
|
||||
// claude -p --output-format json devuelve JSON lines con cada mensaje
|
||||
// El último bloque con role:"assistant" contiene la respuesta final
|
||||
|
||||
type claudeOutputMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
// ... campos adicionales del formato JSON de claude
|
||||
}
|
||||
|
||||
func (p *ClaudeCodeProvider) parseOutput(stdout, stderr []byte, execErr error) (core.CompletionResult, error) {
|
||||
// Parsear JSON lines, extraer último mensaje assistant
|
||||
// Mapear exit code a FinishReason
|
||||
// Extraer token usage si está disponible
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Registro en el arranque
|
||||
|
||||
```go
|
||||
// shell/llm/registry_defaults.go
|
||||
|
||||
func NewDefaultRegistry() *Registry {
|
||||
r := &Registry{factories: make(map[string]Factory)}
|
||||
|
||||
r.Register("sonnet", anthropic.NewAnthropicProvider)
|
||||
r.Register("haiku", anthropic.NewAnthropicProvider)
|
||||
r.Register("opus", anthropic.NewAnthropicProvider)
|
||||
r.Register("claude-code", claudecode.NewClaudeCodeProvider) // ← nuevo
|
||||
|
||||
return r
|
||||
}
|
||||
```
|
||||
|
||||
### Instanciación en el runtime del agente
|
||||
|
||||
```go
|
||||
// agents/runtime.go
|
||||
|
||||
func (a *Agent) init(registry *llm.Registry) error {
|
||||
provider, err := registry.Build(a.cfg.Model, a.cfg.ModelConfig, a.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building LLM provider %q: %w", a.cfg.Model, err)
|
||||
}
|
||||
a.llm = provider
|
||||
return nil
|
||||
}
|
||||
|
||||
// Después, cuando el agente necesita razonar:
|
||||
func (a *Agent) handleMessage(ctx context.Context, msg Message) (string, error) {
|
||||
req := core.CompletionRequest{
|
||||
SystemPrompt: a.systemPrompt,
|
||||
Messages: a.buildMessages(msg),
|
||||
}
|
||||
result, err := a.llm.Complete(ctx, req) // ← no sabe si es HTTP o subproceso
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Content, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diferencia clave vs. modelo HTTP
|
||||
|
||||
| Aspecto | Proveedor HTTP (`sonnet`) | Proveedor Claude Code (`claude-code`) |
|
||||
|---|---|---|
|
||||
| Transporte | HTTP a `api.anthropic.com` | Subproceso local `claude -p` |
|
||||
| Auth | API key | Session de Claude Code (login previo) |
|
||||
| Capacidades extra | Solo texto in/out | Agentic: bash, files, git dentro de `claude -p` |
|
||||
| Latencia | Baja por request | Mayor (startup del proceso + múltiples turnos internos) |
|
||||
| Costo | Por tokens via API | Por tokens via Claude Code (misma cuenta) |
|
||||
| Estado | Stateless | Puede mantener sesión (`--session-id`) |
|
||||
| Working dir | N/A | El worktree del agente |
|
||||
|
||||
---
|
||||
|
||||
## Flatten de mensajes para `claude -p`
|
||||
|
||||
`claude -p` recibe el prompt por stdin como texto plano. Hay que aplanar el historial:
|
||||
|
||||
```go
|
||||
func flattenMessages(msgs []core.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case "user":
|
||||
fmt.Fprintf(&b, "User: %s\n\n", m.Content)
|
||||
case "assistant":
|
||||
fmt.Fprintf(&b, "Assistant: %s\n\n", m.Content)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
```
|
||||
|
||||
Alternativa para conversaciones largas: usar `--session-id` y enviar solo el último mensaje.
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
|
||||
- `core/llm/types.go` — revisar que `CompletionRequest`/`CompletionResult` estén completos
|
||||
- `shell/llm/provider.go` — interfaz `Provider`, `Registry`, `Factory`
|
||||
- `shell/llm/anthropic/provider.go` — proveedor HTTP (refactorizar si ya existe)
|
||||
- **`shell/llm/claudecode/provider.go`** — proveedor Claude Code (nuevo)
|
||||
- `shell/llm/claudecode/parser.go` — parseo de JSON output de `claude -p`
|
||||
- `shell/llm/registry_defaults.go` — registro de proveedores disponibles
|
||||
- `agents/runtime.go` — usar `Registry.Build()` para instanciar el provider del agente
|
||||
- `internal/config/schema.go` — validar `model_config` según el `model` elegido
|
||||
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
- **Fase 1**: Provider básico — stdin/stdout, sin sesiones, timeout simple
|
||||
- **Fase 2**: Soporte de `--session-id` para conversaciones con estado (el agente mantiene el session ID entre interacciones)
|
||||
- **Fase 3**: Streaming — `claude -p --output-format stream-json` para respuestas parciales en tiempo real a la sala Matrix
|
||||
- **Fase 4**: Pool de procesos — reutilizar sesiones de Claude Code para reducir latencia de startup
|
||||
- El agente no necesita implementar tools propios para bash/git/files si usa `claude-code` como modelo — Claude Code ya los tiene
|
||||
- Respetar `ctx` de shutdown: matar el subproceso con `cmd.Process.Kill()` si el contexto se cancela
|
||||
- El `working_dir` debería ser el worktree del agente para que Claude Code tenga contexto del repo
|
||||
@@ -0,0 +1,284 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Tarea 08 — Knowledge por agente
|
||||
|
||||
## Objetivo
|
||||
|
||||
Cada agente tiene una carpeta `knowledge/` donde almacena documentos de conocimiento (markdown).
|
||||
El agente puede buscar, leer, escribir y mejorar su propio conocimiento usando tools siempre disponibles.
|
||||
El conocimiento es archivos reales — inspeccionables por humanos, editables, y se pueden sembrar con contenido inicial.
|
||||
|
||||
## Diseño
|
||||
|
||||
### Almacenamiento híbrido: archivos + índice FTS5
|
||||
|
||||
```
|
||||
agents/<id>/knowledge/ ← archivos .md reales (human-readable)
|
||||
├── go-patterns.md
|
||||
├── user-preferences.md
|
||||
└── matrix-tips.md
|
||||
|
||||
agents/<id>/data/knowledge.db ← índice SQLite FTS5 (búsqueda rápida)
|
||||
```
|
||||
|
||||
- Los documentos viven como archivos `.md` en `knowledge/`.
|
||||
- Un índice FTS5 en SQLite permite búsqueda full-text instantánea.
|
||||
- Al iniciar, se sincroniza: archivos → índice (detecta nuevos, modificados, eliminados).
|
||||
- Al escribir via tool, se actualiza archivo + índice atómicamente.
|
||||
|
||||
### Por qué archivos y no solo SQLite
|
||||
|
||||
1. **Sembrables**: se puede crear `knowledge/` con documentos iniciales antes de arrancar
|
||||
2. **Inspeccionables**: un humano puede leer/editar el conocimiento del agente
|
||||
3. **Git-friendly**: opcionalmente trackeable en el repo
|
||||
4. **Naturales**: el agente "escribe documentos", no inserta rows
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura (pure core / impure shell)
|
||||
|
||||
### 1. Pure core: `pkg/knowledge/`
|
||||
|
||||
```go
|
||||
// pkg/knowledge/types.go
|
||||
package knowledge
|
||||
|
||||
import "time"
|
||||
|
||||
// Document represents a knowledge document.
|
||||
type Document struct {
|
||||
Slug string // filename sin extensión, e.g. "go-patterns"
|
||||
Title string // primera línea H1 del markdown, o slug humanizado
|
||||
Content string // contenido completo del archivo
|
||||
UpdatedAt time.Time // mtime del archivo
|
||||
}
|
||||
|
||||
// SearchResult is a document matched by a search query.
|
||||
type SearchResult struct {
|
||||
Slug string
|
||||
Title string
|
||||
Snippet string // fragmento relevante con match highlights
|
||||
Rank float64 // relevancia FTS5
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// pkg/knowledge/store.go
|
||||
package knowledge
|
||||
|
||||
import "context"
|
||||
|
||||
// Store is the pure interface for knowledge operations.
|
||||
// Implemented by shell/knowledge.
|
||||
type Store interface {
|
||||
// Search performs full-text search across all documents.
|
||||
Search(ctx context.Context, query string, limit int) ([]SearchResult, error)
|
||||
|
||||
// Get retrieves a document by slug.
|
||||
Get(ctx context.Context, slug string) (*Document, error)
|
||||
|
||||
// Put creates or updates a document (file + index).
|
||||
Put(ctx context.Context, doc Document) error
|
||||
|
||||
// Delete removes a document (file + index).
|
||||
Delete(ctx context.Context, slug string) error
|
||||
|
||||
// List returns all document slugs with titles.
|
||||
List(ctx context.Context) ([]Document, error)
|
||||
|
||||
// Sync re-indexes all files from disk. Called on startup.
|
||||
Sync(ctx context.Context) error
|
||||
|
||||
// Close releases resources.
|
||||
Close() error
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Impure shell: `shell/knowledge/`
|
||||
|
||||
```go
|
||||
// shell/knowledge/store.go
|
||||
package knowledge
|
||||
|
||||
// FileStore implements knowledge.Store using files + SQLite FTS5.
|
||||
type FileStore struct {
|
||||
dir string // path a agents/<id>/knowledge/
|
||||
dbPath string // path a agents/<id>/data/knowledge.db
|
||||
db *sql.DB
|
||||
logger *slog.Logger
|
||||
}
|
||||
```
|
||||
|
||||
**Schema SQLite:**
|
||||
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS documents USING fts5(
|
||||
slug,
|
||||
title,
|
||||
content,
|
||||
updated_at UNINDEXED
|
||||
);
|
||||
```
|
||||
|
||||
**Operaciones:**
|
||||
|
||||
| Método | Archivos | SQLite FTS5 |
|
||||
|--------|----------|-------------|
|
||||
| `Sync()` | Lee todos los `.md` del dir | Reconstruye índice completo |
|
||||
| `Search()` | — | `SELECT slug, title, snippet(...) FROM documents WHERE documents MATCH ?` |
|
||||
| `Get()` | Lee `{slug}.md` | — |
|
||||
| `Put()` | Escribe `{slug}.md` | Upsert en FTS5 |
|
||||
| `Delete()` | Borra `{slug}.md` | Delete en FTS5 |
|
||||
| `List()` | — | `SELECT slug, title FROM documents` |
|
||||
|
||||
**Sync al startup:**
|
||||
1. Listar `*.md` en el directorio
|
||||
2. Para cada archivo: leer contenido, extraer título (primer `# ...`), calcular mtime
|
||||
3. `DELETE FROM documents` + re-insertar todo (rebuild completo, simple y correcto)
|
||||
4. Log: `knowledge_sync count=N`
|
||||
|
||||
**Slug rules:**
|
||||
- Solo `[a-z0-9-]`, máximo 64 chars
|
||||
- Derivado del nombre de archivo sin `.md`
|
||||
- El tool valida antes de escribir
|
||||
|
||||
### 3. Tools: `tools/knowledge.go`
|
||||
|
||||
Cuatro tools que el agente siempre tiene disponibles cuando knowledge está habilitado:
|
||||
|
||||
#### `knowledge_search`
|
||||
```
|
||||
Nombre: knowledge_search
|
||||
Descripción: Search your knowledge base for relevant documents. Returns matching snippets ranked by relevance.
|
||||
Parámetros:
|
||||
- query (string, required): Search terms or phrase
|
||||
- limit (integer, optional): Max results, default 5
|
||||
Retorna: Lista de resultados con slug, título y snippet
|
||||
```
|
||||
|
||||
#### `knowledge_read`
|
||||
```
|
||||
Nombre: knowledge_read
|
||||
Descripción: Read the full content of a knowledge document by its slug.
|
||||
Parámetros:
|
||||
- slug (string, required): Document slug (e.g. "go-patterns")
|
||||
Retorna: Contenido completo del documento
|
||||
```
|
||||
|
||||
#### `knowledge_write`
|
||||
```
|
||||
Nombre: knowledge_write
|
||||
Descripción: Create or update a knowledge document. Use this to save new knowledge or improve existing documents.
|
||||
Parámetros:
|
||||
- slug (string, required): Document slug (lowercase, hyphens, e.g. "matrix-tips")
|
||||
- content (string, required): Full markdown content of the document
|
||||
Retorna: Confirmación con slug y tamaño
|
||||
```
|
||||
|
||||
#### `knowledge_list`
|
||||
```
|
||||
Nombre: knowledge_list
|
||||
Descripción: List all documents in your knowledge base with their titles.
|
||||
Parámetros: ninguno
|
||||
Retorna: Lista de slugs con títulos y fecha de última actualización
|
||||
```
|
||||
|
||||
> **Nota:** No incluyo `knowledge_delete` por ahora. Los agentes deberían mejorar y ampliar, no borrar. Si se necesita, se añade después.
|
||||
|
||||
### 4. Config: `internal/config/schema.go`
|
||||
|
||||
```go
|
||||
type KnowledgeCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Dir string `yaml:"dir"` // default: "./knowledge" (relativo al dir del agente)
|
||||
}
|
||||
```
|
||||
|
||||
Añadir a `ToolsCfg`:
|
||||
```go
|
||||
type ToolsCfg struct {
|
||||
// ... existentes ...
|
||||
Knowledge KnowledgeCfg `yaml:"knowledge"`
|
||||
}
|
||||
```
|
||||
|
||||
Config de ejemplo en `config.yaml`:
|
||||
```yaml
|
||||
tools:
|
||||
knowledge:
|
||||
enabled: true
|
||||
dir: "./knowledge" # opcional, default relativo al agente
|
||||
```
|
||||
|
||||
### 5. Registro en runtime: `agents/runtime.go`
|
||||
|
||||
En `buildToolRegistry()`, después de los memory tools:
|
||||
|
||||
```go
|
||||
if cfg.Tools.Knowledge.Enabled {
|
||||
knowledgeDir := resolveKnowledgeDir(cfg) // resolve relative to agent dir
|
||||
knowledgeDBPath := filepath.Join(cfg.Storage.DataDir, "knowledge.db")
|
||||
kStore, err := shellknowledge.New(knowledgeDir, knowledgeDBPath, logger)
|
||||
if err != nil {
|
||||
logger.Error("knowledge_store_init_failed", "err", err)
|
||||
} else {
|
||||
// Sync on startup
|
||||
if err := kStore.Sync(ctx); err != nil {
|
||||
logger.Error("knowledge_sync_failed", "err", err)
|
||||
}
|
||||
reg.Register(tools.NewKnowledgeSearch(kStore))
|
||||
reg.Register(tools.NewKnowledgeRead(kStore))
|
||||
reg.Register(tools.NewKnowledgeWrite(kStore))
|
||||
reg.Register(tools.NewKnowledgeList(kStore))
|
||||
logger.Debug("registered knowledge tools")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plan de implementación (orden)
|
||||
|
||||
### Paso 1 — Pure types (`pkg/knowledge/`)
|
||||
- [ ] `pkg/knowledge/types.go` — Document, SearchResult
|
||||
- [ ] `pkg/knowledge/store.go` — Store interface
|
||||
|
||||
### Paso 2 — Config
|
||||
- [ ] Añadir `KnowledgeCfg` a `internal/config/schema.go` dentro de `ToolsCfg`
|
||||
|
||||
### Paso 3 — Shell store (`shell/knowledge/`)
|
||||
- [ ] `shell/knowledge/store.go` — FileStore con FTS5
|
||||
- Constructor `New(dir, dbPath, logger)`
|
||||
- Sync(), Search(), Get(), Put(), Delete(), List(), Close()
|
||||
- Validación de slugs
|
||||
- Extracción de título del markdown (primer `# `)
|
||||
|
||||
### Paso 4 — Tools (`tools/knowledge.go`)
|
||||
- [ ] `tools/knowledge.go` — NewKnowledgeSearch, NewKnowledgeRead, NewKnowledgeWrite, NewKnowledgeList
|
||||
- [ ] Interface `KnowledgeStore` en tools (subset de knowledge.Store, como se hizo con MemoryStore)
|
||||
|
||||
### Paso 5 — Registro en runtime
|
||||
- [ ] Modificar `buildToolRegistry()` en `agents/runtime.go`
|
||||
- [ ] Resolver directorio de knowledge relativo al agente
|
||||
|
||||
### Paso 6 — Activar en agentes existentes
|
||||
- [ ] Crear `agents/assistant-bot/knowledge/` con un documento semilla
|
||||
- [ ] Crear `agents/asistente-2/knowledge/` con un documento semilla
|
||||
- [ ] Actualizar `config.yaml` de ambos agentes: `tools.knowledge.enabled: true`
|
||||
- [ ] Actualizar system prompts para que el agente sepa que tiene knowledge tools
|
||||
|
||||
### Paso 7 — Tests
|
||||
- [ ] Test de `shell/knowledge/` — sync, search, put, get, list
|
||||
- [ ] Test de `tools/knowledge.go` — validación de slugs, parámetros
|
||||
- [ ] Build completo: `go build -tags goolm ./...`
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso por el agente
|
||||
|
||||
Un usuario le dice al bot: "¿Cómo configuro un webhook en Gitea?"
|
||||
|
||||
1. El agente llama `knowledge_search(query="gitea webhook")`
|
||||
2. Encuentra `gitea-admin.md` con snippet relevante
|
||||
3. Llama `knowledge_read(slug="gitea-admin")` para leer el documento completo
|
||||
4. Responde al usuario con la info
|
||||
5. Si descubre info nueva en la conversación, llama `knowledge_write(slug="gitea-webhooks", content="# Gitea Webhooks\n\n...")` para ampliar su base
|
||||
|
||||
## Diferencia con memory tools
|
||||
|
||||
| Aspecto | Memory (facts) | Knowledge (documents) |
|
||||
|---------|----------------|----------------------|
|
||||
| Granularidad | Key-value individual | Documentos completos |
|
||||
| Búsqueda | Por subject exacto | Full-text search (FTS5) |
|
||||
| Formato | Tripla (subject, key, value) | Markdown libre |
|
||||
| Propósito | Datos puntuales sobre users/temas | Base de conocimiento estructurada |
|
||||
| Persistencia | SQLite rows | Archivos .md + índice FTS5 |
|
||||
| Editable por humanos | No (solo via SQL) | Sí (archivos normales) |
|
||||
|
||||
---
|
||||
|
||||
## Notas de implementación
|
||||
|
||||
- **FTS5 y modernc/sqlite**: modernc.org/sqlite soporta FTS5 nativamente, no necesita CGO.
|
||||
- **Slugs**: validar con regexp `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$` (min 2 chars).
|
||||
- **Título**: extraer primera línea que empiece con `# `. Si no hay, usar slug humanizado.
|
||||
- **Tamaño máximo por documento**: 64 KB (consistente con read_file tool).
|
||||
- **Directorio knowledge/ en .gitignore**: decisión del usuario. Se puede trackear o no.
|
||||
- **No embeddings**: FTS5 keyword search es suficiente para v1. Embeddings es extensión futura.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Task 09 — Sistema de comandos directos (!command)
|
||||
|
||||
## Objetivo
|
||||
|
||||
Implementar un sistema de comandos que permita a los usuarios ejecutar acciones directamente via `!comando` sin depender del LLM. Soportar agentes "simple_bot" que no tienen LLM y solo responden a comandos.
|
||||
|
||||
## Contexto actual
|
||||
|
||||
- `message.Parse` ya detecta `CommandPrefix` (!) y extrae `Command` + `Args` en `MessageContext`
|
||||
- `decision.MatchCommand()` ya existe para matchear comandos en reglas
|
||||
- `tools.Registry` ya tiene `Execute(ctx, name, argsJSON)` para ejecutar tools
|
||||
- Cada agente define sus reglas en `agent.go` con `Rules() []decision.Rule`
|
||||
- El flujo actual: solo `!help` existe como comando hardcodeado en cada agente
|
||||
|
||||
## Problema
|
||||
|
||||
- Los comandos estan hardcodeados en cada `agent.go` como reglas individuales
|
||||
- No hay forma de ejecutar tools directamente sin pasar por el LLM
|
||||
- No hay comandos built-in compartidos entre agentes
|
||||
- No se puede crear un bot sin LLM (simple_bot)
|
||||
- El `!help` es estatico y no refleja las tools reales del agente
|
||||
|
||||
## Diseno
|
||||
|
||||
### Arquitectura (pure core / impure shell)
|
||||
|
||||
```
|
||||
pkg/command/ -> PURE: tipos Command, parser de args, specs built-in
|
||||
agents/runtime.go -> composicion: conecta commands con tools y shell
|
||||
```
|
||||
|
||||
### Tipos de comandos
|
||||
|
||||
1. **Built-in commands** (disponibles en todos los agentes):
|
||||
|
||||
| Comando | Descripcion |
|
||||
|------------|----------------------------------------------------|
|
||||
| `!help` | Lista comandos disponibles (built-in + custom) |
|
||||
| `!tools` | Lista tools registradas con descripcion |
|
||||
| `!ping` | Alive check, responde "pong" con timestamp |
|
||||
| `!status` | Info del agente: uptime, rooms activos, window sizes |
|
||||
| `!info` | Nombre, version, descripcion del agente |
|
||||
| `!clear` | Limpia ventana de conversacion del room actual |
|
||||
| `!version` | Version del agente |
|
||||
|
||||
2. **Tool commands** — ejecutar tools directas:
|
||||
```
|
||||
!tool <nombre> -> sin args
|
||||
!tool <nombre> key=value -> arg simple
|
||||
!tool <nombre> key="valor con espacios" -> arg con espacios
|
||||
!tool <nombre> key=value key2=value2 -> multiples args
|
||||
```
|
||||
Ejemplos:
|
||||
- `!tool ssh_command host=server1 command="uptime"`
|
||||
- `!tool current_time`
|
||||
- `!tool knowledge_search query="como configurar"`
|
||||
|
||||
3. **Custom commands** — definidos por cada agente en su `agent.go` via Rules con MatchCommand (como ahora, pero mejor integrados)
|
||||
|
||||
### Flujo de ejecucion
|
||||
|
||||
```
|
||||
Matrix event
|
||||
-> message.Parse (ya extrae Command + Args)
|
||||
-> handleEvent:
|
||||
1. Si hay Command (empieza con !prefix):
|
||||
a. Custom command del agente (rules con MatchCommand)? -> ejecutar regla
|
||||
b. Built-in command? -> ejecutar handler, responder
|
||||
c. "tool" command? -> parsear args, ejecutar via tools.Registry, responder
|
||||
d. No encontrado? -> responder "comando desconocido, usa !help"
|
||||
2. Si NO es comando: flujo actual (rules -> LLM fallback si hay LLM)
|
||||
3. Si NO es comando y NO hay LLM: ignorar (solo responde a comandos)
|
||||
```
|
||||
|
||||
**Nota**: las reglas custom del agente tienen prioridad sobre built-ins. Si un agente define una regla `MatchCommand("help")` propia, esa gana sobre el built-in.
|
||||
|
||||
### Nuevo paquete `pkg/command/` (puro)
|
||||
|
||||
```go
|
||||
// pkg/command/types.go
|
||||
|
||||
// Spec es la spec pura de un comando. Solo datos.
|
||||
type Spec struct {
|
||||
Name string
|
||||
Aliases []string // e.g. ["h"] para help
|
||||
Description string // descripcion corta para !help
|
||||
Usage string // e.g. "!tool <name> [key=value ...]"
|
||||
Hidden bool // no mostrar en !help
|
||||
}
|
||||
|
||||
// ParsedArgs resultado de parsear "key=value key2=value2"
|
||||
type ParsedArgs struct {
|
||||
Positional []string // args sin key=
|
||||
Named map[string]string // args con key=value
|
||||
Raw []string // args originales
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// pkg/command/parse.go
|
||||
|
||||
// ParseArgs convierte []string{"host=server1", "command=uptime"} en ParsedArgs. Puro.
|
||||
func ParseArgs(args []string) ParsedArgs { ... }
|
||||
|
||||
// ArgsToJSON convierte ParsedArgs.Named a JSON string para tools.Registry.Execute. Puro.
|
||||
func ArgsToJSON(named map[string]string) string { ... }
|
||||
```
|
||||
|
||||
```go
|
||||
// pkg/command/builtins.go
|
||||
|
||||
// Builtins retorna las specs de todos los comandos built-in. Puro.
|
||||
func Builtins() []Spec { ... }
|
||||
```
|
||||
|
||||
### Cambios en `agents/runtime.go`
|
||||
|
||||
```go
|
||||
// CommandHandler ejecuta un comando built-in y devuelve la respuesta texto.
|
||||
type CommandHandler func(ctx context.Context, msgCtx decision.MessageContext) string
|
||||
|
||||
// Nuevos campos en Agent:
|
||||
type Agent struct {
|
||||
// ... existente ...
|
||||
commands map[string]CommandHandler // built-in command handlers
|
||||
startTime time.Time // para !status
|
||||
}
|
||||
```
|
||||
|
||||
En `handleEvent`, el flujo cambia a:
|
||||
```go
|
||||
// 1. Evaluar reglas custom primero (pueden overridear built-ins)
|
||||
if msgCtx.Command != "" {
|
||||
actions := decision.Evaluate(msgCtx, a.rules)
|
||||
if len(actions) > 0 {
|
||||
// ejecutar como ahora (expand LLM actions, runner.Execute)
|
||||
return
|
||||
}
|
||||
// 2. Buscar en built-ins
|
||||
if handler, ok := a.commands[msgCtx.Command]; ok {
|
||||
reply := handler(ctx, msgCtx)
|
||||
a.matrix.SendText(ctx, roomID, reply)
|
||||
return
|
||||
}
|
||||
// 3. Comando desconocido
|
||||
a.matrix.SendText(ctx, roomID, "Comando desconocido. Usa !help")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Sin comando: LLM fallback (si hay LLM) o ignorar
|
||||
if a.llm == nil {
|
||||
return // simple_bot: solo responde a comandos
|
||||
}
|
||||
// ... flujo LLM actual (DM/mention -> LLM) ...
|
||||
```
|
||||
|
||||
### Simple bots (sin LLM)
|
||||
|
||||
Un simple_bot se configura sin seccion `llm` o con `llm.primary.provider: ""`:
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
id: monitor-bot
|
||||
name: Monitor Bot
|
||||
enabled: true
|
||||
description: "Bot de monitoreo, solo comandos"
|
||||
|
||||
tools:
|
||||
ssh:
|
||||
enabled: true
|
||||
allowed_targets: ["webserver"]
|
||||
```
|
||||
|
||||
En `New()`, si no hay LLM configurado, `a.llm` queda nil. El bot solo responde a comandos.
|
||||
|
||||
## Tareas de implementacion
|
||||
|
||||
### Fase 1 — Core puro (`pkg/command/`)
|
||||
- [x] Crear `pkg/command/types.go` — tipos Spec, ParsedArgs
|
||||
- [x] Crear `pkg/command/parse.go` — ParseArgs, ArgsToJSON
|
||||
- [x] Crear `pkg/command/parse_test.go` — tests del parser
|
||||
- [x] Crear `pkg/command/builtins.go` — specs de los 7 comandos built-in + BuiltinNames()
|
||||
|
||||
### Fase 2 — Handlers en runtime (`agents/`)
|
||||
- [x] Agregar campos `commands`, `cmdAliases`, `startTime` al Agent struct
|
||||
- [x] Implementar handlers: help, tools, ping, info, version, clear, status
|
||||
- [x] Implementar handler `tool` — parsea args key=value, ejecuta via Registry, formatea respuesta
|
||||
- [x] Registrar todos los handlers en `New()` via `registerBuiltinCommands()`
|
||||
- [x] Modificar `handleEvent` — nuevo flujo: rules custom -> built-in -> comando desconocido -> LLM fallback
|
||||
- [x] Extraer `executeActions()` helper para reutilizar en ambos flujos
|
||||
|
||||
### Fase 3 — Simple bot support
|
||||
- [x] Hacer LLM opcional en `New()` (no fallar si no hay provider)
|
||||
- [x] Si `a.llm == nil` y no hay comando, ignorar mensaje
|
||||
- [ ] Verificar que un agente sin LLM arranca y responde a !help, !tool, !ping
|
||||
|
||||
### Fase 4 — Integracion con agentes existentes
|
||||
- [x] Eliminar regla `!help` hardcodeada de assistant-bot/agent.go
|
||||
- [x] Eliminar regla `!help` hardcodeada de asistente-2/agent.go
|
||||
- [x] Verificar que reglas custom (llm-all, etc.) siguen funcionando (build OK)
|
||||
- [ ] Test manual: !help, !tools, !tool current_time, !ping, !status, !clear, !info, !version
|
||||
|
||||
### Fase 5 (futura) — Simple bot de ejemplo
|
||||
- [ ] Crear agente simple_bot de ejemplo sin LLM
|
||||
- [ ] Documentar patron simple_bot
|
||||
@@ -0,0 +1,79 @@
|
||||
# Tarea 11 — Renderizar mensajes como Markdown en Matrix
|
||||
|
||||
## Problema
|
||||
|
||||
Todos los mensajes de los agentes (respuestas LLM, comandos, errores) se envían como texto plano
|
||||
via `SendText()`. Matrix soporta mensajes con `format: org.matrix.custom.html` + `formatted_body`
|
||||
para renderizar Markdown (negrita, código, listas, etc.) en clientes como Element.
|
||||
|
||||
Existe un `SendMarkdown()` en `shell/matrix/client.go` pero tiene dos problemas:
|
||||
1. Solo se usa en un único lugar (`runtime.go:617` — notificación de tool use).
|
||||
2. No convierte Markdown a HTML: pone el markdown crudo en `FormattedBody`, que Matrix espera como HTML.
|
||||
|
||||
## Alcance
|
||||
|
||||
### 1. Añadir conversión Markdown → HTML (`shell/matrix/client.go`)
|
||||
|
||||
- Añadir dependencia `github.com/yuin/goldmark` (parser Markdown → HTML estándar, muy usado en Go).
|
||||
- Corregir `SendMarkdown()` para que convierta el body de Markdown a HTML antes de ponerlo en `FormattedBody`.
|
||||
- `Body` queda como texto plano (fallback para clientes que no soportan HTML) — se puede dejar el markdown crudo ahí, que es lo estándar en Matrix.
|
||||
|
||||
```go
|
||||
func (c *Client) SendMarkdown(ctx context.Context, roomID, markdown string) error {
|
||||
html := mdToHTML(markdown) // nueva función interna
|
||||
content := event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: markdown,
|
||||
Format: event.FormatHTML,
|
||||
FormattedBody: html,
|
||||
}
|
||||
_, err := c.raw.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Cambiar la interfaz `MatrixSender` para exponer `SendMarkdown`
|
||||
|
||||
- `shell/effects/runner.go`: añadir `SendMarkdown(ctx, roomID, text) error` a la interfaz `MatrixSender`.
|
||||
- `tools/matrix.go`: añadir `SendMarkdown` a la interfaz `MatrixToolSender` (o como se llame).
|
||||
|
||||
### 3. Cambiar todos los call sites de `SendText` → `SendMarkdown`
|
||||
|
||||
Puntos a cambiar:
|
||||
|
||||
| Archivo | Línea(s) | Contexto |
|
||||
|---------|----------|----------|
|
||||
| `agents/runtime.go:394` | Respuesta de tarea orquestada | `SendText → SendMarkdown` |
|
||||
| `agents/runtime.go:456` | Reply LLM (loop) | `SendText → SendMarkdown` |
|
||||
| `agents/runtime.go:462` | Reply LLM (fallback) | `SendText → SendMarkdown` |
|
||||
| `shell/effects/runner.go:68` | Runner.executeOne (ActionKindReply) | `SendText → SendMarkdown` |
|
||||
| `agents/runtime.go:456` | Comando ejecutado (!xxx) | `SendText → SendMarkdown` |
|
||||
| `agents/runtime.go:462` | Comando desconocido | `SendText → SendMarkdown` |
|
||||
|
||||
### 4. Mantener `SendText` para uso interno/futuro
|
||||
|
||||
No eliminar `SendText`, solo dejar de usarlo como canal principal de respuesta.
|
||||
Podría ser útil para mensajes que realmente no necesitan formato (logs internos, debugging).
|
||||
|
||||
### 5. Actualizar interfaz en tests/mocks
|
||||
|
||||
Cualquier mock de `MatrixSender` que exista en tests necesitará el método `SendMarkdown`.
|
||||
|
||||
## Tareas ordenadas
|
||||
|
||||
- [ ] `go get github.com/yuin/goldmark`
|
||||
- [ ] Crear función `mdToHTML(md string) string` en `shell/matrix/` (usa goldmark)
|
||||
- [ ] Corregir `SendMarkdown()` para usar `mdToHTML`
|
||||
- [ ] Añadir `SendMarkdown` a la interfaz `MatrixSender` en `shell/effects/runner.go`
|
||||
- [ ] Cambiar `runner.executeOne` (ActionKindReply) de `SendText` → `SendMarkdown`
|
||||
- [ ] Cambiar `runtime.go` — respuesta de comandos (!xxx) a `SendMarkdown`
|
||||
- [ ] Cambiar `runtime.go` — respuesta de tarea orquestada a `SendMarkdown`
|
||||
- [ ] Actualizar interfaz en `tools/matrix.go` si aplica
|
||||
- [ ] Actualizar mocks en tests
|
||||
- [ ] Test manual: enviar mensaje al bot y verificar que Element renderiza markdown
|
||||
|
||||
## Notas
|
||||
|
||||
- goldmark es safe por defecto (escapa HTML peligroso) — no hay riesgo XSS.
|
||||
- El `Body` del evento Matrix queda como markdown crudo — esto es correcto según la spec de Matrix (es el fallback plaintext).
|
||||
- Los mensajes de error simples ("Comando desconocido: !foo") también pasan por `SendMarkdown` — no pasa nada, goldmark los deja como `<p>texto</p>` sin más.
|
||||
Reference in New Issue
Block a user