Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-04 19:02:34 +00:00
parent e900464dd6
commit 062dac268f
9 changed files with 347 additions and 53 deletions
+252 -53
View File
@@ -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/<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)
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.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
- 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.01.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/<id>/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
+2
View File
@@ -6,3 +6,5 @@ bin/
launcher
run/*.pid
run/*.log
agentctl
+2
View File
@@ -50,6 +50,8 @@ func main() {
startCmd(&binPath),
stopCmd(),
removeCmd(),
avatarCmd(),
displaynameCmd(),
)
if err := root.Execute(); err != nil {
+36
View File
@@ -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 <agent-id> <image-path>
#
# 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 <agent-id> <image-path>"
[[ -n "$IMAGE_PATH" ]] || fail "Uso: $0 <agent-id> <image-path>"
[[ -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."
+55
View File
@@ -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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB