diff --git a/.claude/plans/03-bot-interaction.md b/.claude/plans/03-bot-interaction.md index eb32206..de5f6f0 100644 --- a/.claude/plans/03-bot-interaction.md +++ b/.claude/plans/03-bot-interaction.md @@ -1,79 +1,278 @@ -# Plan: Interacción entre bots en una sala compartida +# Plan: Multi-bot Orchestration — Middleware invisible ## Objetivo -Que múltiples bots convivan en una sala Matrix, se "escuchen" entre sí y puedan -colaborar en tareas — sin crear bucles infinitos. +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: pendiente --- -## Problema central: evitar bucles -Si bot-A responde a bot-B y bot-B responde a bot-A, se genera un bucle. +## Arquitectura: `agents/specials/` -### Solución: reglas de anti-bucle en el motor de decisión -1. Cada mensaje lleva metadatos `sender` y opcionalmente `m.relates_to` -2. El `MatchFunc` de las reglas puede filtrar por `IsBot(sender)` y - aplicar lógica de cooldown o turno -3. El sistema de bus interno (`shell/bus/`) puede coordinar turnos +Los **special agents** son componentes de sistema sin identidad Matrix. Viven en +`agents/specials//` 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) + devops/ → bot normal + 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 | --- -## Diseño +## Config del orquestador -### Nuevo tipo de sala: `SharedRoom` -Configurar en `config.yaml` bajo una sección `rooms`: ```yaml -rooms: - - id: "!roomid:matrix.server.com" - type: "shared" # sala compartida entre bots - participants: # agentes que participan - - assistant-bot - - devops-bot - turn_based: false # si true, los bots se turnan por ronda - respond_to_bots: true # si false, solo responden a humanos +# 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 + - id: devops-bot ``` -### Lógica en `agents/runtime.go` -- Al recibir un evento en una sala compartida, verificar si `sender` es un bot conocido -- Aplicar una regla de "bot puede responder a bot" solo si: - - La sala tiene `respond_to_bots: true` - - No hay respuesta pendiente del mismo bot en los últimos N segundos (cooldown) - - No es una respuesta a un mensaje propio - -### Coordinación con el bus (`shell/bus/`) -- Publicar en el bus interno cuando un bot habla en una sala compartida -- Los otros bots suscritos al bus pueden reaccionar sin pasar por Matrix -- Posible uso: bot-A pide ayuda a bot-B por el bus, bot-B responde en Matrix - --- -## Reglas puras (`pkg/decision/`) -Nuevas funciones de match: +## 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 -func SenderIsBot(knownBots []string) MatchFunc -func NotRepliedRecently(cooldown time.Duration) MatchFunc // requiere estado externo -func SenderIsHuman(knownBots []string) MatchFunc +// 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 +} ``` --- -## Anti-bucle: cooldown simple -En `agents/runtime.go` mantener `map[roomID]time.Time` con el último mensaje enviado. -Si `now - lastSent < cooldown`, no responder en esa sala. -Cooldown configurable: `rooms[].response_cooldown_seconds`. +## 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 + - devops-bot: SSH, deployments, infraestructura + 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: [...] +``` --- -## Archivos a crear/modificar -- `internal/config/schema.go` — añadir `RoomCfg` con campos shared room -- `pkg/decision/matchers.go` — SenderIsBot, SenderIsHuman -- `agents/runtime.go` — lógica de salas compartidas y cooldown -- `shell/bus/bus.go` — publicar eventos cross-bot -- `agents//agent.go` — reglas específicas para salas compartidas +## Comportamiento de los bots en sala orquestada -## Notas -- Fase 1: solo cooldown simple — un bot no responde más de 1 vez por minuto en - una sala compartida -- Fase 2: turno basado en bus interno -- Fase 3: colaboración estructurada (bot-A delega tarea a bot-B y espera resultado) +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] devops-bot respondió · evaluando..."` → topic actualizado en tiempo real diff --git a/.gitignore b/.gitignore index b5f6438..0b5dd1e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ bin/ launcher run/*.pid run/*.log + +agentctl \ No newline at end of file diff --git a/cmd/agentctl/main.go b/cmd/agentctl/main.go index 32abe55..4e59412 100644 --- a/cmd/agentctl/main.go +++ b/cmd/agentctl/main.go @@ -50,6 +50,8 @@ func main() { startCmd(&binPath), stopCmd(), removeCmd(), + avatarCmd(), + displaynameCmd(), ) if err := root.Execute(); err != nil { diff --git a/dev-scripts/avatar.sh b/dev-scripts/avatar.sh new file mode 100755 index 0000000..997c4d4 --- /dev/null +++ b/dev-scripts/avatar.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# avatar.sh — sube una imagen y la establece como avatar de un bot. +# También sincroniza el displayname desde el config (agent.name). +# +# Uso: +# ./dev-scripts/avatar.sh +# +# Ejemplos: +# ./dev-scripts/avatar.sh assistant-bot assets/assistant.png +# ./dev-scripts/avatar.sh devops-bot assets/devops.jpg + +source "$(dirname "$0")/_common.sh" +load_env + +AGENT_ID="${1:-}" +IMAGE_PATH="${2:-}" + +[[ -n "$AGENT_ID" ]] || fail "Uso: $0 " +[[ -n "$IMAGE_PATH" ]] || fail "Uso: $0 " +[[ -f "$IMAGE_PATH" ]] || fail "Imagen no encontrada: $IMAGE_PATH" + +# Resuelve el binario de agentctl: compiled > go run +if [[ -f "$REPO_ROOT/bin/agentctl" ]]; then + CTL="$REPO_ROOT/bin/agentctl" +else + info "bin/agentctl no encontrado, usando go run ./cmd/agentctl" + CTL="$GO run ./cmd/agentctl" +fi + +info "Subiendo avatar para $AGENT_ID desde $IMAGE_PATH..." +$CTL avatar "$AGENT_ID" "$IMAGE_PATH" + +info "Sincronizando displayname desde config..." +$CTL displayname "$AGENT_ID" + +ok "Perfil de $AGENT_ID actualizado." diff --git a/shell/matrix/profile.go b/shell/matrix/profile.go new file mode 100644 index 0000000..b4dce6e --- /dev/null +++ b/shell/matrix/profile.go @@ -0,0 +1,55 @@ +package matrix + +import ( + "context" + "fmt" + "mime" + "os" + "path/filepath" + + "maunium.net/go/mautrix" +) + +// SetAvatar uploads the image at filePath to the Matrix media repository +// and sets it as the bot's avatar. Returns the mxc:// URI of the upload. +func (c *Client) SetAvatar(ctx context.Context, filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("open %s: %w", filePath, err) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return "", fmt.Errorf("stat %s: %w", filePath, err) + } + + mimeType := mime.TypeByExtension(filepath.Ext(filePath)) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + resp, err := c.raw.UploadMedia(ctx, mautrix.ReqUploadMedia{ + Content: f, + ContentLength: info.Size(), + ContentType: mimeType, + FileName: filepath.Base(filePath), + }) + if err != nil { + return "", fmt.Errorf("upload media: %w", err) + } + + if err := c.raw.SetAvatarURL(ctx, resp.ContentURI); err != nil { + return "", fmt.Errorf("set avatar URL: %w", err) + } + + return resp.ContentURI.String(), nil +} + +// SetDisplayName sets the bot's display name on the Matrix homeserver. +func (c *Client) SetDisplayName(ctx context.Context, name string) error { + if err := c.raw.SetDisplayName(ctx, name); err != nil { + return fmt.Errorf("set display name: %w", err) + } + return nil +} diff --git a/static/admin_bot.jpg b/static/admin_bot.jpg new file mode 100644 index 0000000..18579ca Binary files /dev/null and b/static/admin_bot.jpg differ diff --git a/static/assistant.jpg b/static/assistant.jpg new file mode 100644 index 0000000..36a0c94 Binary files /dev/null and b/static/assistant.jpg differ diff --git a/static/assistant_2.jpg b/static/assistant_2.jpg new file mode 100644 index 0000000..e0f38e3 Binary files /dev/null and b/static/assistant_2.jpg differ diff --git a/static/image_assistant.jpg b/static/image_assistant.jpg new file mode 100644 index 0000000..d421156 Binary files /dev/null and b/static/image_assistant.jpg differ