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
|
## Objetivo
|
||||||
Que múltiples bots convivan en una sala Matrix, se "escuchen" entre sí y puedan
|
Cuando hay más de un bot en una sala, un **orquestador invisible** (sin identidad
|
||||||
colaborar en tareas — sin crear bucles infinitos.
|
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
|
## Estado: pendiente
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problema central: evitar bucles
|
## Arquitectura: `agents/specials/`
|
||||||
Si bot-A responde a bot-B y bot-B responde a bot-A, se genera un bucle.
|
|
||||||
|
|
||||||
### Solución: reglas de anti-bucle en el motor de decisión
|
Los **special agents** son componentes de sistema sin identidad Matrix. Viven en
|
||||||
1. Cada mensaje lleva metadatos `sender` y opcionalmente `m.relates_to`
|
`agents/specials/<id>/` y el launcher los instancia de forma diferente a los bots
|
||||||
2. El `MatchFunc` de las reglas puede filtrar por `IsBot(sender)` y
|
normales: sin token, sin listener propio, sin `user_id`.
|
||||||
aplicar lógica de cooldown o turno
|
|
||||||
3. El sistema de bus interno (`shell/bus/`) puede coordinar turnos
|
```
|
||||||
|
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
|
```yaml
|
||||||
rooms:
|
# agents/specials/orchestrator/config.yaml
|
||||||
- id: "!roomid:matrix.server.com"
|
|
||||||
type: "shared" # sala compartida entre bots
|
special:
|
||||||
participants: # agentes que participan
|
id: orchestrator
|
||||||
- assistant-bot
|
type: orchestrator # clave para que el launcher sepa cómo instanciarlo
|
||||||
- devops-bot
|
enabled: true
|
||||||
turn_based: false # si true, los bots se turnan por ronda
|
description: "Middleware de coordinación multi-bot. Sin identidad Matrix."
|
||||||
respond_to_bots: true # si false, solo responden a humanos
|
|
||||||
|
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/`)
|
## Flujo de eventos
|
||||||
Nuevas funciones de match:
|
|
||||||
|
```
|
||||||
|
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
|
```go
|
||||||
func SenderIsBot(knownBots []string) MatchFunc
|
// pkg/orchestration/task.go
|
||||||
func NotRepliedRecently(cooldown time.Duration) MatchFunc // requiere estado externo
|
type TaskEvent struct {
|
||||||
func SenderIsHuman(knownBots []string) MatchFunc
|
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
|
## LLM calls del orquestador
|
||||||
En `agents/runtime.go` mantener `map[roomID]time.Time` con el último mensaje enviado.
|
|
||||||
Si `now - lastSent < cooldown`, no responder en esa sala.
|
### Call 1: Routing inicial
|
||||||
Cooldown configurable: `rooms[].response_cooldown_seconds`.
|
```
|
||||||
|
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
|
## Comportamiento de los bots en sala orquestada
|
||||||
- `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
|
|
||||||
|
|
||||||
## Notas
|
Los bots **no saben** que están siendo orquestados. El launcher simplemente no
|
||||||
- Fase 1: solo cooldown simple — un bot no responde más de 1 vez por minuto en
|
les entrega el evento Matrix directamente. En su lugar reciben un `TaskEvent`
|
||||||
una sala compartida
|
via bus con el contexto correcto.
|
||||||
- Fase 2: turno basado en bus interno
|
|
||||||
- Fase 3: colaboración estructurada (bot-A delega tarea a bot-B y espera resultado)
|
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
|
launcher
|
||||||
run/*.pid
|
run/*.pid
|
||||||
run/*.log
|
run/*.log
|
||||||
|
|
||||||
|
agentctl
|
||||||
@@ -50,6 +50,8 @@ func main() {
|
|||||||
startCmd(&binPath),
|
startCmd(&binPath),
|
||||||
stopCmd(),
|
stopCmd(),
|
||||||
removeCmd(),
|
removeCmd(),
|
||||||
|
avatarCmd(),
|
||||||
|
displaynameCmd(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := root.Execute(); err != nil {
|
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