Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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.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/<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
|
||||
|
||||
@@ -6,3 +6,5 @@ bin/
|
||||
launcher
|
||||
run/*.pid
|
||||
run/*.log
|
||||
|
||||
agentctl
|
||||
@@ -50,6 +50,8 @@ func main() {
|
||||
startCmd(&binPath),
|
||||
stopCmd(),
|
||||
removeCmd(),
|
||||
avatarCmd(),
|
||||
displaynameCmd(),
|
||||
)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
|
||||
Executable
+36
@@ -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."
|
||||
@@ -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 |
Reference in New Issue
Block a user