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:
2026-03-07 17:41:16 +00:00
parent 9deffc12c9
commit f561f686c4
21 changed files with 38 additions and 26 deletions
+52
View File
@@ -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
+95
View File
@@ -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)
+275
View File
@@ -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.01.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.01.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
+69
View File
@@ -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
+284
View File
@@ -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.
+205
View File
@@ -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.