# 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: pendiente

---

## 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
