refactor: migrar tasks/ a dev/issues/ con estructura de desarrollo
Se mueve la documentación de issues/tasks de .claude/tasks/ a dev/issues/ para separar la planificación de desarrollo de la configuración de Claude. Se añade dev/README.md como índice de la carpeta de desarrollo. Los issues completados se mueven a dev/issues/completed/. Esto permite que dev/ sea el punto central de documentación interna del proyecto. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# Plan: Cron scheduler para actividad autónoma de los bots
|
||||
|
||||
## Objetivo
|
||||
Que los bots puedan publicar mensajes, ejecutar tareas o interactuar en salas
|
||||
de forma autónoma según un horario — sin que el usuario tenga que escribirles.
|
||||
|
||||
## Estado: pendiente
|
||||
|
||||
---
|
||||
|
||||
## Casos de uso
|
||||
- Bot saluda "buenos días" en una sala a las 9:00
|
||||
- Devops-bot hace healthcheck de servidores cada hora y reporta
|
||||
- Assistant-bot publica un resumen diario a las 18:00
|
||||
- Bots conversan entre sí a horas fijas para simular actividad
|
||||
|
||||
---
|
||||
|
||||
## Diseño
|
||||
|
||||
### Config YAML — `schedules` (ya existe en el schema)
|
||||
```yaml
|
||||
schedules:
|
||||
- cron: "0 9 * * *" # cada día a las 9:00
|
||||
action: send_message
|
||||
room: "!roomid:server.com"
|
||||
template: "prompts/good-morning.md" # se envía como mensaje o como prompt al LLM
|
||||
|
||||
- cron: "0 * * * *" # cada hora
|
||||
action: run_tool
|
||||
tool: ssh_command
|
||||
args:
|
||||
host: "prod-server"
|
||||
command: "systemctl is-active myapp"
|
||||
|
||||
- cron: "0 18 * * *"
|
||||
action: llm_prompt
|
||||
room: "!roomid:server.com"
|
||||
prompt: "Genera un resumen del día de hoy para el equipo."
|
||||
```
|
||||
|
||||
### Tipos de acción de cron
|
||||
| Tipo | Descripción |
|
||||
|-----------------|-------------------------------------------------------|
|
||||
| `send_message` | Envía un mensaje literal o desde plantilla a una sala |
|
||||
| `run_tool` | Ejecuta una herramienta (SSH, HTTP, etc.) |
|
||||
| `llm_prompt` | Llama al LLM con un prompt y publica la respuesta |
|
||||
|
||||
---
|
||||
|
||||
## Implementación: `shell/cron/`
|
||||
|
||||
```go
|
||||
// Scheduler lanza goroutines para cada schedule configurado
|
||||
type Scheduler struct {
|
||||
agent *agents.Agent
|
||||
cfg []config.ScheduleCfg
|
||||
effects *effects.Runner
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context)
|
||||
func (s *Scheduler) Stop()
|
||||
```
|
||||
|
||||
Usa `time.AfterFunc` o una librería cron mínima.
|
||||
|
||||
### Librería cron recomendada
|
||||
`github.com/robfig/cron/v3` — ligera, soporta sintaxis cron estándar y `@every 1h`.
|
||||
Sin dependencias de CGO.
|
||||
|
||||
### Integración en `agents/runtime.go`
|
||||
```go
|
||||
type Agent struct {
|
||||
...
|
||||
scheduler *cron.Scheduler // nil si no hay schedules
|
||||
}
|
||||
|
||||
func (a *Agent) Start(ctx) error {
|
||||
...
|
||||
if len(a.cfg.Schedules) > 0 {
|
||||
a.scheduler = cron.New(a, a.cfg.Schedules, a.runner)
|
||||
a.scheduler.Start(ctx)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Flujo para `llm_prompt`
|
||||
1. El cron dispara
|
||||
2. Construir `CompletionRequest` con el prompt del schedule
|
||||
3. Llamar al LLM (usando `shell/llm/`)
|
||||
4. Emitir `SendMessageAction` con la respuesta
|
||||
5. El Runner lo envía a la sala Matrix configurada
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
- `shell/cron/scheduler.go` — Scheduler, parseador de ScheduleCfg
|
||||
- `shell/cron/actions.go` — ejecutores de cada tipo de acción de cron
|
||||
- `internal/config/schema.go` — revisar/completar `ScheduleCfg` (ya tiene campos)
|
||||
- `agents/runtime.go` — instanciar y arrancar el Scheduler
|
||||
- `go.mod` — añadir `github.com/robfig/cron/v3`
|
||||
|
||||
## Notas
|
||||
- El Scheduler corre en goroutines separadas; respetar el `ctx` de shutdown
|
||||
- Los prompts de los schedules pueden ser strings inline o rutas a archivos `.md`
|
||||
- Fase 1: solo `send_message` y `llm_prompt`
|
||||
- Fase 2: `run_tool` con resultado incluido en el mensaje
|
||||
- Fase 3: schedules de interacción entre bots (bot-A pide a bot-B que haga algo)
|
||||
@@ -0,0 +1,253 @@
|
||||
# Task 10 — Control de acceso a agentes por usuario
|
||||
|
||||
## Objetivo
|
||||
|
||||
Implementar un sistema de control de acceso que permita restringir qué usuarios pueden interactuar con cada agente, usando la infraestructura `SecurityCfg` ya existente (declarada pero no enforceada).
|
||||
|
||||
## Contexto actual
|
||||
|
||||
- `SecurityCfg` ya existe en `internal/config/schema.go` con `Roles` (map de rol -> users + actions)
|
||||
- `FiltersCfg` ya tiene `ignore_users` (blocklist) y `min_power_level`
|
||||
- `shouldHandle()` en `shell/matrix/listener.go` filtra por room y blocklist, pero NO por allowlist
|
||||
- Auto-join de invites es incondicional (acepta cualquier invite)
|
||||
- `MatchMinPowerLevel()` en `pkg/decision/engine.go` existe pero `powerLevel` siempre se pasa como 0
|
||||
- Los configs de agentes ya definen roles pero nadie los verifica:
|
||||
```yaml
|
||||
security:
|
||||
roles:
|
||||
admin:
|
||||
users: ["@admin:matrix-af2f3d.organic-machine.com"]
|
||||
actions: ["*"]
|
||||
user:
|
||||
users: ["*"]
|
||||
actions: ["ask", "help", "summarize"]
|
||||
```
|
||||
|
||||
## Problema
|
||||
|
||||
- Cualquier usuario del homeserver (o federado) puede invitar a un bot y hablar con el
|
||||
- No hay forma de restringir acceso por usuario — los bots son publicos
|
||||
- Los roles configurados en `security.roles` no se verifican en ningun punto
|
||||
- El auto-join acepta invites de cualquiera sin verificar permisos
|
||||
- No hay distincion entre acciones permitidas por rol (admin vs user)
|
||||
|
||||
## Diseno
|
||||
|
||||
### Arquitectura (pure core / impure shell)
|
||||
|
||||
```
|
||||
pkg/acl/ -> PURE: tipos AccessList, CheckAccess(), ExtractRole()
|
||||
shell/matrix/listener.go -> IMPURE: aplica ACL en shouldHandle() y auto-join
|
||||
agents/runtime.go -> composicion: pasa ACL al listener, verifica roles en comandos
|
||||
```
|
||||
|
||||
### Modelo de acceso
|
||||
|
||||
Tres niveles de control, cada uno incrementa la restriccion:
|
||||
|
||||
1. **Nivel 1 — Allowlist de usuarios** (quien puede hablar con el bot)
|
||||
2. **Nivel 2 — Invite gating** (quien puede invitar al bot a una sala)
|
||||
3. **Nivel 3 — RBAC por accion** (quien puede ejecutar que comandos/acciones)
|
||||
|
||||
### Nivel 1 — Allowlist en FiltersCfg
|
||||
|
||||
Agregar `allowed_users` a `FiltersCfg`:
|
||||
|
||||
```go
|
||||
// internal/config/schema.go
|
||||
type FiltersCfg struct {
|
||||
// ... existente ...
|
||||
AllowedUsers []string `yaml:"allowed_users"` // allowlist (vacio = todos)
|
||||
}
|
||||
```
|
||||
|
||||
Config YAML:
|
||||
```yaml
|
||||
matrix:
|
||||
filters:
|
||||
allowed_users:
|
||||
- "@admin:matrix-af2f3d.organic-machine.com"
|
||||
- "@enmanuel:matrix-af2f3d.organic-machine.com"
|
||||
# vacio o ausente = sin restriccion (todos pueden hablar)
|
||||
```
|
||||
|
||||
Verificacion en `shouldHandle()`:
|
||||
```go
|
||||
// Despues de los filtros existentes, antes de return true
|
||||
if len(f.AllowedUsers) > 0 {
|
||||
allowed := false
|
||||
for _, u := range f.AllowedUsers {
|
||||
if evt.Sender.String() == u {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
l.logger.Debug("ignoring unauthorized user", "sender", evt.Sender)
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nivel 2 — Invite gating
|
||||
|
||||
Modificar el handler de `StateMember` invite para verificar quien invita:
|
||||
|
||||
```go
|
||||
// shell/matrix/listener.go — en el handler de invites
|
||||
if membership != event.MembershipInvite {
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar si el invitante esta autorizado
|
||||
if len(l.cfg.Filters.AllowedUsers) > 0 {
|
||||
allowed := false
|
||||
for _, u := range l.cfg.Filters.AllowedUsers {
|
||||
if evt.Sender.String() == u {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
l.logger.Info("rejecting invite from unauthorized user",
|
||||
"room", evt.RoomID, "inviter", evt.Sender)
|
||||
// Opcion: leave room o simplemente no joinear
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-join (existente)
|
||||
l.client.raw.JoinRoom(ctx, evt.RoomID.String(), "", nil)
|
||||
```
|
||||
|
||||
### Nivel 3 — RBAC por accion (conectar SecurityCfg.Roles)
|
||||
|
||||
#### Nuevo paquete `pkg/acl/` (puro)
|
||||
|
||||
```go
|
||||
// pkg/acl/types.go
|
||||
|
||||
// Role representa un rol con sus usuarios y acciones permitidas.
|
||||
type Role struct {
|
||||
Name string
|
||||
Users []string // Matrix user IDs, "*" = todos
|
||||
Actions []string // acciones permitidas, "*" = todas
|
||||
}
|
||||
|
||||
// ACL contiene la lista de control de acceso resuelta.
|
||||
type ACL struct {
|
||||
Roles []Role
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// pkg/acl/check.go
|
||||
|
||||
// FromConfig construye un ACL desde el mapa de roles del config. Puro.
|
||||
func FromConfig(roles map[string]config.RoleCfg) ACL { ... }
|
||||
|
||||
// RoleFor devuelve el nombre del rol con mayor privilegio para un userID. Puro.
|
||||
// Prioridad: el primer rol especifico que matchee; si ninguno, busca "*".
|
||||
// Si no hay match, retorna "" (sin rol = sin acceso si RBAC esta activo).
|
||||
func (a ACL) RoleFor(userID string) string { ... }
|
||||
|
||||
// CanDo verifica si un userID puede ejecutar una accion. Puro.
|
||||
// Si no hay roles definidos, retorna true (sin RBAC = acceso libre).
|
||||
// Si hay roles pero el usuario no tiene ninguno, retorna false.
|
||||
func (a ACL) CanDo(userID string, action string) bool { ... }
|
||||
|
||||
// AllowedUsers retorna la lista consolidada de todos los userIDs
|
||||
// con al menos un rol (excluyendo "*"). Util para allowlist. Puro.
|
||||
func (a ACL) AllowedUsers() []string { ... }
|
||||
```
|
||||
|
||||
#### Integracion en runtime.go
|
||||
|
||||
```go
|
||||
// agents/runtime.go — en handleEvent, despues de evaluar el comando
|
||||
|
||||
// Para comandos built-in, verificar accion "command:<name>"
|
||||
if handler, ok := a.commands[msgCtx.Command]; ok {
|
||||
if !a.acl.CanDo(msgCtx.SenderID, "command:"+msgCtx.Command) {
|
||||
a.matrix.SendText(ctx, roomID, "No tienes permisos para este comando.")
|
||||
return
|
||||
}
|
||||
reply := handler(ctx, msgCtx)
|
||||
// ...
|
||||
}
|
||||
|
||||
// Para tool commands, verificar accion "tool:<name>"
|
||||
// Para LLM fallback, verificar accion "ask" (o la que corresponda)
|
||||
```
|
||||
|
||||
#### Mapeo de acciones
|
||||
|
||||
| Accion config | Que protege |
|
||||
|----------------|----------------------------------------------|
|
||||
| `*` | Todo (wildcard) |
|
||||
| `ask` | Hablar con el LLM (mensajes de texto libre) |
|
||||
| `command:*` | Todos los comandos !xxx |
|
||||
| `command:tool` | Ejecutar !tool |
|
||||
| `command:clear`| Ejecutar !clear |
|
||||
| `tool:*` | Todas las tools via LLM |
|
||||
| `tool:ssh_command` | Tool SSH especifica |
|
||||
| `help` | Comandos informativos (!help, !info, !status)|
|
||||
|
||||
### Retrocompatibilidad
|
||||
|
||||
- Si `allowed_users` esta vacio → sin restriccion (como ahora)
|
||||
- Si `security.roles` esta vacio → sin RBAC (como ahora)
|
||||
- El comportamiento por defecto NO cambia — todo sigue abierto a menos que se configure
|
||||
|
||||
### Respuesta a usuarios no autorizados
|
||||
|
||||
Dos estrategias configurables:
|
||||
|
||||
1. **Silent** (default): ignorar mensajes de usuarios no autorizados (como si el bot no existiera)
|
||||
2. **Explicit**: responder con "No tienes permisos para interactuar con este agente"
|
||||
|
||||
```yaml
|
||||
matrix:
|
||||
filters:
|
||||
allowed_users: [...]
|
||||
unauthorized_response: "silent" # silent | explicit
|
||||
```
|
||||
|
||||
## Tareas de implementacion
|
||||
|
||||
### Fase 1 — Allowlist basica (Nivel 1)
|
||||
- [ ] Agregar `AllowedUsers []string` a `FiltersCfg` en `internal/config/schema.go`
|
||||
- [ ] Agregar `UnauthorizedResponse string` a `FiltersCfg` (`silent` | `explicit`)
|
||||
- [ ] Implementar check de allowlist en `shouldHandle()` de `shell/matrix/listener.go`
|
||||
- [ ] Si `unauthorized_response: explicit`, responder antes de retornar false
|
||||
- [ ] Tests: shouldHandle con allowlist vacia (pasa todo), con lista (filtra)
|
||||
|
||||
### Fase 2 — Invite gating (Nivel 2)
|
||||
- [ ] Modificar handler de `StateMember` invite en listener.go
|
||||
- [ ] Verificar invitante contra `allowed_users` antes de auto-join
|
||||
- [ ] Si no autorizado: no joinear (y opcionalmente leave/reject)
|
||||
- [ ] Log de invites rechazados
|
||||
|
||||
### Fase 3 — RBAC puro (Nivel 3)
|
||||
- [ ] Crear `pkg/acl/types.go` — tipos Role, ACL
|
||||
- [ ] Crear `pkg/acl/check.go` — FromConfig, RoleFor, CanDo, AllowedUsers
|
||||
- [ ] Crear `pkg/acl/check_test.go` — tests exhaustivos del ACL puro
|
||||
- [ ] Tests: wildcard "*" en users, wildcard "*" en actions, sin roles, multiples roles
|
||||
|
||||
### Fase 4 — Conectar RBAC al runtime
|
||||
- [ ] Construir ACL en `agents/runtime.go` New() desde `cfg.Security.Roles`
|
||||
- [ ] Verificar permisos antes de ejecutar comandos built-in
|
||||
- [ ] Verificar permisos antes de ejecutar tools (via LLM y via !tool)
|
||||
- [ ] Verificar permiso "ask" antes de enviar al LLM
|
||||
- [ ] Respuesta de "sin permisos" respetuosa cuando se deniega
|
||||
|
||||
### Fase 5 — Config y documentacion
|
||||
- [ ] Actualizar configs de assistant-bot y asistente-2 con ejemplo de allowed_users
|
||||
- [ ] Documentar en `docs/creating-agents.md` la seccion de control de acceso
|
||||
- [ ] Verificar que agentes sin security config siguen funcionando (retrocompat)
|
||||
|
||||
### Fase 6 (futura) — Extensiones
|
||||
- [ ] Audit log: registrar intentos de acceso denegados en audit log
|
||||
- [ ] Patron glob en users: `@*:matrix-af2f3d.organic-machine.com` (solo usuarios locales)
|
||||
- [ ] Rate limiting por rol (admin sin limite, user con rate limit)
|
||||
- [ ] Comando `!acl` para admins: ver roles activos, verificar permisos de un usuario
|
||||
@@ -0,0 +1,89 @@
|
||||
# Task 011 — Matrix Thread Support
|
||||
|
||||
## Objetivo
|
||||
|
||||
Permitir que los agentes mantengan conversaciones en threads de Matrix (`m.thread`),
|
||||
de forma que cada interaccion con un usuario pueda vivir en un hilo separado
|
||||
en lugar de la timeline principal del room.
|
||||
|
||||
## Contexto
|
||||
|
||||
Matrix soporta threads via `m.relates_to` con `rel_type: "m.thread"`.
|
||||
Un thread siempre referencia un **evento raiz** y opcionalmente incluye
|
||||
`m.in_reply_to` como fallback para clientes sin soporte de threads.
|
||||
|
||||
```json
|
||||
{
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": "$rootEventId",
|
||||
"is_falling_back": true,
|
||||
"m.in_reply_to": {
|
||||
"event_id": "$lastEventInThread"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisito
|
||||
|
||||
- Task: Reply simple (`m.in_reply_to`) ya implementado.
|
||||
|
||||
## Plan de implementacion
|
||||
|
||||
### 1. Detectar threads entrantes en el Listener
|
||||
|
||||
- En `shell/matrix/listener.go`, al parsear el evento, extraer `m.relates_to`
|
||||
- Si `rel_type == "m.thread"`, capturar `event_id` como `ThreadRootID`
|
||||
- Propagar `ThreadRootID` en `MessageContext`
|
||||
|
||||
### 2. Extender MessageContext
|
||||
|
||||
- `pkg/decision/types.go`: anadir `ThreadRootID string` (el evento raiz del thread)
|
||||
- Esto es dato puro, no rompe la arquitectura
|
||||
|
||||
### 3. Extender ReplyAction
|
||||
|
||||
- `pkg/decision/types.go`: anadir `ThreadRootID string` a `ReplyAction`
|
||||
- El runner usara esto para decidir si enviar como thread o como mensaje normal
|
||||
|
||||
### 4. SendThreadMarkdown en Client
|
||||
|
||||
- `shell/matrix/client.go`: nuevo metodo `SendThreadMarkdown(ctx, roomID, threadRootID, inReplyTo, markdown)`
|
||||
- Construye el `m.relates_to` con `rel_type: "m.thread"` + fallback `m.in_reply_to`
|
||||
|
||||
### 5. Actualizar effects/Runner
|
||||
|
||||
- `shell/effects/runner.go`: si `ReplyAction.ThreadRootID != ""`, usar `SendThreadMarkdown`
|
||||
- Actualizar interfaz `MatrixSender` con el nuevo metodo
|
||||
|
||||
### 6. Propagacion en runtime.go
|
||||
|
||||
- Cuando el mensaje entrante ya esta en un thread (`msgCtx.ThreadRootID != ""`),
|
||||
las respuestas del bot deben continuar en ese thread
|
||||
- Cuando el usuario inicia una conversacion nueva, decidir segun config si crear thread o no
|
||||
|
||||
### 7. Configuracion por agente
|
||||
|
||||
- `internal/config/schema.go`: anadir opcion `matrix.threads.enabled: bool` y
|
||||
`matrix.threads.auto_thread: bool` (crear thread automatico por cada conversacion nueva)
|
||||
- Default: `enabled: true`, `auto_thread: false`
|
||||
|
||||
### 8. Memory por thread
|
||||
|
||||
- La window de conversacion deberia poder ser por thread en vez de por room
|
||||
- Si `ThreadRootID != ""`, usar `threadRootID` como key de la window en vez de `roomID`
|
||||
- Esto permite conversaciones paralelas en threads distintos sin mezclarse
|
||||
|
||||
### 9. Tests
|
||||
|
||||
- Unit tests para `SendThreadMarkdown` (verificar estructura JSON)
|
||||
- Test de integracion: listener detecta thread entrante y propaga ThreadRootID
|
||||
- Test: respuesta dentro de thread mantiene el thread root correcto
|
||||
|
||||
## Notas
|
||||
|
||||
- `is_falling_back: true` siempre debe estar cuando se usa thread + in_reply_to fallback
|
||||
- El `event_id` de `m.relates_to` (nivel top) siempre apunta al root del thread, nunca cambia
|
||||
- El `m.in_reply_to` dentro del thread apunta al ultimo mensaje respondido
|
||||
- Clientes sin soporte de threads ven el fallback como un reply normal
|
||||
@@ -0,0 +1,265 @@
|
||||
# Task 013 — Hot-Reload de Agentes Individuales
|
||||
|
||||
## Objetivo
|
||||
|
||||
Permitir reiniciar (recrear) un agente individual dentro del launcher sin detener
|
||||
los demas agentes. El bus y el orquestador permanecen intactos porque todo sigue
|
||||
en el mismo proceso.
|
||||
|
||||
## Contexto
|
||||
|
||||
Actualmente el launcher ejecuta todos los agentes como goroutines dentro de un
|
||||
unico proceso. No hay forma de reiniciar un solo agente — hay que matar y
|
||||
re-arrancar el launcher entero, lo que desconecta a todos los bots de Matrix
|
||||
y rompe conversaciones en curso.
|
||||
|
||||
### Por que no un proceso por agente
|
||||
|
||||
El sistema de orquestacion multi-bot depende de:
|
||||
|
||||
- **Bus in-process** (`shell/bus/bus.go`): Go channels, solo funciona dentro del mismo proceso.
|
||||
- **Orquestador** (`shell/orchestration/`): usa el bus para `dispatchAndWait()` (request-response).
|
||||
- **Deduplicacion** (`seen map`): estado compartido en memoria para evitar que multiples bots
|
||||
en el mismo room procesen el mismo mensaje.
|
||||
- **Interceptor**: callback sincrono que el listener de cada bot llama al orquestador.
|
||||
|
||||
Separar en procesos romperia todo lo anterior. El hot-reload mantiene el proceso unico
|
||||
pero recrea el agente internamente.
|
||||
|
||||
## Mecanismo propuesto
|
||||
|
||||
### Signal: SIGHUP + archivo de control
|
||||
|
||||
1. El launcher escucha `SIGHUP` ademas de SIGINT/SIGTERM.
|
||||
2. Al recibir SIGHUP, lee un archivo `run/reload.txt` que contiene el ID del agente a recargar.
|
||||
3. Si el archivo no existe o esta vacio, recarga TODOS los agentes.
|
||||
4. Alternativa: un comando via bus (`bus.KindReload`) enviado desde el TUI/agentctl.
|
||||
|
||||
### Flujo de hot-reload
|
||||
|
||||
```
|
||||
SIGHUP recibido (o comando reload via bus/TUI)
|
||||
|
|
||||
v
|
||||
Launcher lee run/reload.txt -> agentID (o "*" para todos)
|
||||
|
|
||||
v
|
||||
Para cada agente a recargar:
|
||||
1. Cancelar su context (ctx.cancel) -> Agent.Run() termina gracefully
|
||||
2. Esperar a que la goroutine termine (via WaitGroup o done channel)
|
||||
3. Desuscribir del bus (bus.Unsubscribe(agentID))
|
||||
4. Re-leer config.yaml del agente
|
||||
5. Re-crear Agent con agents.New(cfg, rules, logger)
|
||||
6. Re-suscribir al bus (agent.SetBus)
|
||||
7. Re-conectar interceptor y membership notify si orquestador activo
|
||||
8. Re-registrar participante en orquestador
|
||||
9. Lanzar nueva goroutine con agent.Run(newCtx)
|
||||
|
|
||||
v
|
||||
Log: "agent <id> reloaded successfully"
|
||||
```
|
||||
|
||||
## Plan de implementacion
|
||||
|
||||
### 1. Hacer Agent cancelable individualmente
|
||||
|
||||
**Archivo**: `agents/runtime.go`
|
||||
|
||||
- Actualmente `Agent.Run(ctx)` recibe el context del launcher (compartido).
|
||||
- Cambiar para que cada agente tenga su propio `context.WithCancel(parentCtx)`.
|
||||
- Exponer un metodo `Agent.Stop()` que cancela el context hijo.
|
||||
- Exponer un canal o metodo `Agent.Done() <-chan struct{}` para saber cuando termino.
|
||||
|
||||
```go
|
||||
type Agent struct {
|
||||
// ... campos existentes ...
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
ctx, a.cancel = context.WithCancel(ctx)
|
||||
defer close(a.done)
|
||||
// ... resto del Run existente ...
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() {
|
||||
if a.cancel != nil {
|
||||
a.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) Done() <-chan struct{} {
|
||||
return a.done
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Anadir Unsubscribe al bus
|
||||
|
||||
**Archivo**: `shell/bus/bus.go`
|
||||
|
||||
- Nuevo metodo `Unsubscribe(id AgentID)` que elimina el canal del mapa y lo cierra.
|
||||
- `listenBus()` en runtime.go debe manejar canal cerrado sin panic.
|
||||
|
||||
```go
|
||||
func (b *Bus) Unsubscribe(id AgentID) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if ch, ok := b.channels[id]; ok {
|
||||
close(ch)
|
||||
delete(b.channels, id)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Tracker de agentes en el launcher
|
||||
|
||||
**Archivo**: `cmd/launcher/main.go`
|
||||
|
||||
- Reemplazar el `sync.WaitGroup` actual por un registry de agentes vivos:
|
||||
|
||||
```go
|
||||
type runningAgent struct {
|
||||
agent *agents.Agent
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
type agentRegistry struct {
|
||||
mu sync.Mutex
|
||||
agents map[string]*runningAgent
|
||||
}
|
||||
```
|
||||
|
||||
- Metodos: `register(id, agent)`, `stop(id)`, `reload(id, parentCtx)`, `stopAll()`.
|
||||
- `reload(id)` ejecuta el flujo descrito arriba: stop -> wait -> recreate -> start.
|
||||
|
||||
### 4. Handler de SIGHUP
|
||||
|
||||
**Archivo**: `cmd/launcher/main.go`
|
||||
|
||||
- Escuchar SIGHUP en un canal separado (no en el mismo NotifyContext de SIGINT/SIGTERM).
|
||||
- Al recibir SIGHUP:
|
||||
- Leer `run/reload.txt` (si existe)
|
||||
- Llamar `registry.reload(id, ctx)` o `registry.reloadAll(ctx)` si es "*"
|
||||
|
||||
```go
|
||||
sighup := make(chan os.Signal, 1)
|
||||
signal.Notify(sighup, syscall.SIGHUP)
|
||||
|
||||
go func() {
|
||||
for range sighup {
|
||||
id := readReloadTarget("run/reload.txt")
|
||||
if id == "" || id == "*" {
|
||||
registry.reloadAll(ctx)
|
||||
} else {
|
||||
registry.reload(id, ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
### 5. Integracion con el orquestador
|
||||
|
||||
**Archivo**: `cmd/launcher/main.go` (dentro de `reload()`)
|
||||
|
||||
Al recrear un agente que participa en orquestacion:
|
||||
|
||||
1. El orquestador no necesita "desregistrar" al participante — basta con re-registrar
|
||||
con la misma info (sobreescribe).
|
||||
2. Re-llamar `SetInterceptor` y `SetMembershipNotify` en el nuevo Agent.
|
||||
3. El bus.Subscribe del nuevo agente devuelve un canal nuevo — el orquestador usa
|
||||
`bus.Send(agentID)` que resuelve el nuevo canal automaticamente.
|
||||
|
||||
**Caso critico**: si el agente esta en medio de un `dispatchAndWait()` cuando se cancela:
|
||||
- El context se cancela -> SendAndWait retorna error
|
||||
- El orquestador recibe timeout/error para esa iteracion
|
||||
- La respuesta parcial se pierde pero no hay corrupcion
|
||||
- El orquestador puede reintentar o pasar al siguiente bot
|
||||
|
||||
### 6. Integracion con el TUI
|
||||
|
||||
**Archivos**: `pkg/tui/update.go`, `shell/tui/adapter.go`, `shell/process/manager.go`
|
||||
|
||||
El boton "Restart" del TUI (task actual) debe cambiar de "kill+start launcher" a:
|
||||
|
||||
1. Escribir el agentID en `run/reload.txt`
|
||||
2. Enviar SIGHUP al proceso del launcher (`kill -HUP <pid>`)
|
||||
3. Esperar un momento y refrescar estado
|
||||
|
||||
```go
|
||||
func (a *Adapter) restartAgent(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Escribir target en reload file
|
||||
os.WriteFile("run/reload.txt", []byte(id), 0644)
|
||||
// Enviar SIGHUP al launcher
|
||||
pid := a.mgr.UnifiedPID()
|
||||
if pid > 0 {
|
||||
syscall.Kill(pid, syscall.SIGHUP)
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: nil}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Integracion con agentctl CLI
|
||||
|
||||
**Archivo**: `cmd/agentctl/main.go`
|
||||
|
||||
- Nuevo subcomando: `agentctl reload <agent-id>`
|
||||
- Escribe `run/reload.txt` + envia SIGHUP
|
||||
- Mismo mecanismo que el TUI
|
||||
|
||||
### 8. Graceful shutdown del agente
|
||||
|
||||
**Archivo**: `agents/runtime.go`
|
||||
|
||||
Al cancelar el context individual de un agente:
|
||||
|
||||
1. El sync loop de Matrix debe detenerse limpiamente (mautrix tiene `StopSync()`)
|
||||
2. Las llamadas LLM en curso deben cancelarse via context
|
||||
3. La tool execution en curso debe respetar context cancellation
|
||||
4. Memory/knowledge stores deben flush antes de cerrar
|
||||
5. El canal del bus se cierra — `listenBus` sale del loop
|
||||
|
||||
Verificar que `runtime.go:Run()` ya maneja todo esto con el context actual.
|
||||
Si no, anadir cleanup explicicto.
|
||||
|
||||
### 9. Tests
|
||||
|
||||
- **Unit test**: `bus.Unsubscribe` no causa panic, mensajes posteriores al unsubscribe
|
||||
no se pierden (retornan error).
|
||||
- **Unit test**: `agentRegistry.reload()` — stop + recreate funciona.
|
||||
- **Integration test**: enviar SIGHUP y verificar que solo el agente target se reinicia.
|
||||
- **Orchestrator test**: agente en medio de task, se cancela, orquestador maneja el error.
|
||||
|
||||
## Orden de implementacion sugerido
|
||||
|
||||
1. `Agent.Stop()` + `Agent.Done()` (runtime.go)
|
||||
2. `Bus.Unsubscribe()` (bus.go)
|
||||
3. `agentRegistry` en launcher (main.go)
|
||||
4. Handler SIGHUP (main.go)
|
||||
5. Graceful shutdown verification (runtime.go)
|
||||
6. Actualizar TUI adapter (adapter.go)
|
||||
7. Actualizar agentctl (agentctl/main.go)
|
||||
8. Tests
|
||||
|
||||
## Riesgos y mitigaciones
|
||||
|
||||
| Riesgo | Mitigacion |
|
||||
|--------|------------|
|
||||
| Race condition al cerrar canal del bus | Mutex en Unsubscribe, recover en Send |
|
||||
| Crypto store de mautrix queda locked | Cerrar store explicitamente en cleanup |
|
||||
| Orquestador en medio de dispatch | Context cancellation + timeout ya existente |
|
||||
| Config invalido al recargar | Validar config antes de destruir agente viejo |
|
||||
| Matrix sync no para limpio | Llamar StopSync() explicitamente antes de cancel |
|
||||
|
||||
## Notas
|
||||
|
||||
- SIGHUP es la convencion Unix para "recargar configuracion" (nginx, haproxy, etc.)
|
||||
- El archivo `run/reload.txt` es efimero — se puede borrar despues de leer
|
||||
- Si el launcher no esta corriendo, el TUI debe caer al comportamiento actual (start launcher)
|
||||
- El orquestador NO se recarga — solo los agentes. Para recargar el orquestador
|
||||
hay que reiniciar el launcher entero.
|
||||
@@ -0,0 +1,464 @@
|
||||
# 014 — Agente plantilla + sistema de personalidades + estandarizacion
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear un agente plantilla (no lanzable) que sirva como referencia canonica para la configuracion de todos los agentes. Incluir un sistema de personalidades rico que permita definir agentes con caracteres distintos. Enriquecer `!info` para mostrar metadata completa. Estandarizar los config.yaml existentes integrando las nuevas capacidades del proyecto: skills, shared-knowledge, cron jobs.
|
||||
|
||||
## Contexto
|
||||
|
||||
- El launcher descubre agentes via `agents/*/config.yaml` (glob en cmd/launcher/main.go)
|
||||
- `!info` existe como built-in en `agents/commands.go` pero solo muestra: nombre, ID, version, descripcion
|
||||
- No hay herencia de configs ni template base — cada config.yaml es autocontenido
|
||||
- Agentes actuales: assistant-bot, asistente-2
|
||||
- La seccion `personality` actual es basica: tone, verbosity, emoji_style, templates, behavior
|
||||
- Nuevas capacidades en desarrollo: skills (016), shared-knowledge (018), cron jobs (005)
|
||||
|
||||
---
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Sistema de personalidades enriquecido
|
||||
|
||||
El sistema actual (`pkg/personality/traits.go` + `PersonalityCfg` en schema.go) define tone, verbosity, emoji, error_style, templates y behavior. Esto es funcional pero plano — todos los agentes terminan sonando igual con variaciones menores.
|
||||
|
||||
El objetivo es ampliar la personalidad para que cada agente tenga un **caracter unico** que se refleje en como habla, piensa y actua.
|
||||
|
||||
- [ ] **1.1** Ampliar `PersonalityCfg` en `internal/config/schema.go` con nuevos campos:
|
||||
|
||||
```go
|
||||
type PersonalityCfg struct {
|
||||
// --- campos existentes (sin cambios) ---
|
||||
Tone string `yaml:"tone"`
|
||||
Verbosity string `yaml:"verbosity"`
|
||||
Language string `yaml:"language"`
|
||||
LanguagesSupported []string `yaml:"languages_supported"`
|
||||
EmojiStyle string `yaml:"emoji_style"`
|
||||
Prefix string `yaml:"prefix"`
|
||||
ErrorStyle string `yaml:"error_style"`
|
||||
Templates TemplatesCfg `yaml:"templates"`
|
||||
Behavior BehaviorCfg `yaml:"behavior"`
|
||||
|
||||
// --- NUEVOS campos ---
|
||||
// Identidad narrativa
|
||||
Role string `yaml:"role"` // rol principal: "asistente general", "devops engineer", "analista de datos"
|
||||
Backstory string `yaml:"backstory"` // breve historia/contexto del personaje (1-3 frases)
|
||||
Expertise []string `yaml:"expertise"` // areas de experiencia: ["linux", "docker", "monitoring"]
|
||||
Limitations []string `yaml:"limitations"` // que NO sabe o no debe intentar
|
||||
|
||||
// Estilo de comunicacion
|
||||
Communication CommunicationCfg `yaml:"communication"`
|
||||
|
||||
// Directivas de comportamiento en texto libre
|
||||
CustomDirectives []string `yaml:"custom_directives"` // instrucciones adicionales para el system prompt
|
||||
}
|
||||
|
||||
// CommunicationCfg define como se expresa el agente mas alla del tone basico.
|
||||
type CommunicationCfg struct {
|
||||
Formality string `yaml:"formality"` // formal | semiformal | casual | coloquial
|
||||
Humor string `yaml:"humor"` // none | subtle | moderate | frequent
|
||||
Personality string `yaml:"personality"` // analytical | creative | pragmatic | empathetic | assertive
|
||||
ResponseStyle string `yaml:"response_style"` // structured | conversational | bullet_points | narrative
|
||||
Quirks []string `yaml:"quirks"` // rasgos unicos: ["usa analogias de cocina", "cita a Linus Torvalds"]
|
||||
AvoidTopics []string `yaml:"avoid_topics"` // temas que evita o redirige
|
||||
Catchphrases []string `yaml:"catchphrases"` // frases tipicas que usa ocasionalmente
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **1.2** Ampliar tipos puros en `pkg/personality/traits.go`:
|
||||
|
||||
```go
|
||||
type Formality string
|
||||
const (
|
||||
FormalityFormal Formality = "formal"
|
||||
FormalitySemiformal Formality = "semiformal"
|
||||
FormalityCasual Formality = "casual"
|
||||
FormalityColoquial Formality = "coloquial"
|
||||
)
|
||||
|
||||
type Humor string
|
||||
const (
|
||||
HumorNone Humor = "none"
|
||||
HumorSubtle Humor = "subtle"
|
||||
HumorModerate Humor = "moderate"
|
||||
HumorFrequent Humor = "frequent"
|
||||
)
|
||||
|
||||
type PersonalityType string
|
||||
const (
|
||||
PersonalityAnalytical PersonalityType = "analytical"
|
||||
PersonalityCreative PersonalityType = "creative"
|
||||
PersonalityPragmatic PersonalityType = "pragmatic"
|
||||
PersonalityEmpathetic PersonalityType = "empathetic"
|
||||
PersonalityAssertive PersonalityType = "assertive"
|
||||
)
|
||||
|
||||
type ResponseStyle string
|
||||
const (
|
||||
ResponseStructured ResponseStyle = "structured"
|
||||
ResponseConversational ResponseStyle = "conversational"
|
||||
ResponseBulletPoints ResponseStyle = "bullet_points"
|
||||
ResponseNarrative ResponseStyle = "narrative"
|
||||
)
|
||||
```
|
||||
|
||||
Ampliar el struct `Personality` con los nuevos campos correspondientes.
|
||||
|
||||
- [ ] **1.3** Crear funcion `BuildPersonalityPrompt(cfg PersonalityCfg) string` en `pkg/personality/` que genere un bloque de system prompt a partir de la config de personalidad. Esta funcion es **pura** — recibe config, devuelve string. El runtime la usa para inyectar personalidad en el prompt del LLM.
|
||||
|
||||
El prompt generado debe incluir:
|
||||
- Rol y backstory
|
||||
- Expertise y limitaciones
|
||||
- Estilo de comunicacion (formality, humor, personality, response_style)
|
||||
- Quirks y catchphrases
|
||||
- Custom directives
|
||||
- Todo redactado como instrucciones naturales para el LLM
|
||||
|
||||
Ejemplo de output:
|
||||
```
|
||||
## Tu personalidad
|
||||
|
||||
Eres un ingeniero DevOps senior con 10 anos de experiencia en Linux y containers.
|
||||
|
||||
**Rol**: DevOps engineer especializado en infraestructura y monitoring.
|
||||
**Expertise**: Linux, Docker, Kubernetes, Prometheus, bash scripting.
|
||||
**Limitaciones**: No das consejos de frontend ni diseno UI.
|
||||
|
||||
**Como te comunicas**:
|
||||
- Tono semiformal, directo pero amable
|
||||
- Humor sutil — algun comentario ironico cuando algo falla de forma obvia
|
||||
- Estilo pragmatico — siempre priorizas la solucion sobre la teoria
|
||||
- Respuestas estructuradas con comandos claros
|
||||
- A veces citas a Linus Torvalds o usas analogias mecanicas
|
||||
|
||||
**Directivas especiales**:
|
||||
- Siempre sugiere verificar con un dry-run antes de ejecutar cambios destructivos
|
||||
- Cuando algo falla, muestra el log relevante antes de diagnosticar
|
||||
```
|
||||
|
||||
- [ ] **1.4** Integrar `BuildPersonalityPrompt` en `agents/runtime.go` — concatenar el bloque de personalidad al system prompt leido del archivo. El orden debe ser: system prompt del archivo + bloque de personalidad generado.
|
||||
|
||||
### Fase 2: Agente plantilla con personalidades de ejemplo
|
||||
|
||||
- [ ] **2.1** Anadir campo `Template bool` a `AgentMeta` en `internal/config/schema.go`
|
||||
|
||||
- [ ] **2.2** Filtrar agentes template en `cmd/launcher/main.go` — skip si `cfg.Agent.Template == true`
|
||||
|
||||
- [ ] **2.3** Crear `agents/_template/config.yaml` — referencia canonica con TODAS las secciones. Incluir:
|
||||
|
||||
**Identidad**:
|
||||
```yaml
|
||||
agent:
|
||||
id: "_template"
|
||||
name: "Template Agent"
|
||||
version: "0.0.0"
|
||||
enabled: true
|
||||
template: true # el launcher ignora este agente
|
||||
description: "Agente plantilla. No se lanza. Sirve como referencia para crear nuevos agentes."
|
||||
tags: [template]
|
||||
```
|
||||
|
||||
**Personalidad completa** (con todos los campos nuevos documentados):
|
||||
```yaml
|
||||
personality:
|
||||
# --- Identidad narrativa ---
|
||||
role: "asistente general"
|
||||
backstory: "Un asistente amigable creado para ayudar con tareas cotidianas."
|
||||
expertise: [general]
|
||||
limitations: []
|
||||
|
||||
# --- Estilo basico ---
|
||||
tone: friendly # direct | friendly | formal | casual | technical
|
||||
verbosity: concise # minimal | concise | detailed | verbose
|
||||
language: es
|
||||
languages_supported: [es, en]
|
||||
emoji_style: minimal # none | minimal | moderate | heavy
|
||||
prefix: ""
|
||||
error_style: helpful # terse | helpful | detailed
|
||||
|
||||
# --- Comunicacion avanzada ---
|
||||
communication:
|
||||
formality: semiformal # formal | semiformal | casual | coloquial
|
||||
humor: none # none | subtle | moderate | frequent
|
||||
personality: pragmatic # analytical | creative | pragmatic | empathetic | assertive
|
||||
response_style: structured # structured | conversational | bullet_points | narrative
|
||||
quirks: [] # rasgos unicos del personaje
|
||||
avoid_topics: [] # temas a evitar
|
||||
catchphrases: [] # frases tipicas
|
||||
|
||||
# --- Directivas libres ---
|
||||
custom_directives: [] # instrucciones extra para el system prompt
|
||||
|
||||
# --- Templates de respuesta ---
|
||||
templates:
|
||||
greeting: "Hola, soy {name}. En que puedo ayudarte?"
|
||||
unknown_command: "No entiendo ese comando. Usa !help."
|
||||
permission_denied: "No tienes permiso para eso."
|
||||
error: "Algo salio mal: {{.Error}}"
|
||||
success: "{{.Summary}}"
|
||||
busy: "Estoy procesando otra solicitud, un momento..."
|
||||
|
||||
# --- Comportamiento ---
|
||||
behavior:
|
||||
proactive: false
|
||||
ask_confirmation: false
|
||||
show_reasoning: false
|
||||
thread_replies: true
|
||||
typing_indicator: true
|
||||
acknowledge_receipt: false
|
||||
```
|
||||
|
||||
**Skills** (nueva seccion):
|
||||
```yaml
|
||||
skills:
|
||||
enabled: false
|
||||
path: "skills/" # ruta base de skills (relativa al proyecto)
|
||||
categories: [] # vacio = todas las categorias | ["devops", "system"] = filtradas
|
||||
```
|
||||
|
||||
**Shared knowledge** (nueva seccion):
|
||||
```yaml
|
||||
# Dentro de tools:
|
||||
tools:
|
||||
# ... tools existentes ...
|
||||
|
||||
shared_knowledge:
|
||||
enabled: false
|
||||
dir: "knowledges" # directorio compartido
|
||||
db_path: "knowledges/data/knowledge.db"
|
||||
```
|
||||
|
||||
**Schedules con ejemplos**:
|
||||
```yaml
|
||||
schedules:
|
||||
# - name: "buenos-dias"
|
||||
# cron: "0 9 * * 1-5"
|
||||
# action:
|
||||
# kind: llm_prompt
|
||||
# target: "Buenos dias equipo. Dame un resumen rapido del estado de los servicios."
|
||||
# output_room: "!roomid:server.com"
|
||||
# on_failure:
|
||||
# notify_room: ""
|
||||
# escalate_to: ""
|
||||
```
|
||||
|
||||
Incluir TODAS las demas secciones (llm, matrix, agents, ssh, security, observability, resilience, storage, memory) con valores por defecto documentados.
|
||||
|
||||
- [ ] **2.4** Crear `agents/_template/agent.go` minimo con `Rules()` retornando slice vacio
|
||||
|
||||
- [ ] **2.5** Crear `agents/_template/prompts/system.md` con un system prompt plantilla que muestre donde va cada seccion (instrucciones base, personalidad inyectada automaticamente, tools disponibles, etc.)
|
||||
|
||||
- [ ] **2.6** Actualizar `dev-scripts/agent/new-agent.sh` para copiar desde `_template/` en lugar de generar inline
|
||||
|
||||
### Fase 3: Ejemplos de personalidades distintas
|
||||
|
||||
Para demostrar que el sistema funciona, definir perfiles de personalidad que se puedan usar como punto de partida. Estos van como comentarios/documentacion en el template, NO como agentes reales.
|
||||
|
||||
- [ ] **3.1** Documentar en `agents/_template/PERSONALITIES.md` al menos 4 perfiles de ejemplo:
|
||||
|
||||
**Perfil: DevOps pragmatico**
|
||||
```yaml
|
||||
personality:
|
||||
role: "ingeniero DevOps senior"
|
||||
backstory: "Veterano de infraestructura con cicatrices de guerra de incidentes en produccion."
|
||||
expertise: [linux, docker, kubernetes, monitoring, bash, networking]
|
||||
limitations: ["no da consejos de frontend", "no hace diseno UI"]
|
||||
tone: direct
|
||||
verbosity: concise
|
||||
communication:
|
||||
formality: semiformal
|
||||
humor: subtle
|
||||
personality: pragmatic
|
||||
response_style: structured
|
||||
quirks: ["usa analogias mecanicas", "siempre pide ver los logs primero"]
|
||||
catchphrases: ["primero los logs, despues las teorias", "en produccion no se experimenta"]
|
||||
custom_directives:
|
||||
- "Siempre sugiere dry-run antes de cambios destructivos"
|
||||
- "Incluye el comando exacto, no solo la descripcion"
|
||||
```
|
||||
|
||||
**Perfil: Analista meticuloso**
|
||||
```yaml
|
||||
personality:
|
||||
role: "analista de datos"
|
||||
backstory: "Obsesionado con los patrones y las anomalias. Nada escapa a su atencion."
|
||||
expertise: [analisis de logs, metricas, estadistica, patrones de errores]
|
||||
limitations: ["no ejecuta cambios en produccion", "no toma decisiones operativas"]
|
||||
tone: technical
|
||||
verbosity: detailed
|
||||
communication:
|
||||
formality: formal
|
||||
humor: none
|
||||
personality: analytical
|
||||
response_style: structured
|
||||
quirks: ["siempre cuantifica", "pide rango de fechas antes de analizar"]
|
||||
catchphrases: ["los datos no mienten", "correlacion no implica causalidad"]
|
||||
```
|
||||
|
||||
**Perfil: Asistente amigable**
|
||||
```yaml
|
||||
personality:
|
||||
role: "asistente personal"
|
||||
backstory: "Siempre dispuesto a ayudar, paciente y claro en sus explicaciones."
|
||||
expertise: [tareas generales, redaccion, organizacion, resumen]
|
||||
limitations: ["no tiene acceso a servidores", "no ejecuta codigo"]
|
||||
tone: friendly
|
||||
verbosity: concise
|
||||
communication:
|
||||
formality: casual
|
||||
humor: subtle
|
||||
personality: empathetic
|
||||
response_style: conversational
|
||||
quirks: ["pregunta si quieres mas detalle", "celebra cuando termina una tarea"]
|
||||
catchphrases: ["listo!", "algo mas en lo que pueda ayudar?"]
|
||||
```
|
||||
|
||||
**Perfil: Guardian de seguridad**
|
||||
```yaml
|
||||
personality:
|
||||
role: "especialista en seguridad"
|
||||
backstory: "Paranoico profesional. Asume que todo esta comprometido hasta demostrar lo contrario."
|
||||
expertise: [seguridad, auditoria, permisos, CVEs, hardening]
|
||||
limitations: ["no implementa features", "no optimiza performance"]
|
||||
tone: formal
|
||||
verbosity: detailed
|
||||
communication:
|
||||
formality: formal
|
||||
humor: none
|
||||
personality: assertive
|
||||
response_style: bullet_points
|
||||
quirks: ["siempre menciona el principio de minimo privilegio", "pide MFA para todo"]
|
||||
catchphrases: ["confiar pero verificar", "eso necesita un CVE review"]
|
||||
custom_directives:
|
||||
- "Nunca sugieras deshabilitar firewalls o SELinux como solucion"
|
||||
- "Siempre recomienda rotar credenciales despues de un incidente"
|
||||
```
|
||||
|
||||
### Fase 4: Enriquecer `!info`
|
||||
|
||||
- [ ] **4.1** Modificar el handler de `!info` en `agents/commands.go` para que devuelva:
|
||||
- Nombre, ID, version, descripcion
|
||||
- Personalidad: role, tone, formality, personality type, humor
|
||||
- LLM: provider + modelo
|
||||
- Tools habilitadas (lista de nombres)
|
||||
- Skills habilitadas (si/no + categorias + cantidad)
|
||||
- Knowledge: privado (si/no), compartido (si/no)
|
||||
- Memoria: si/no + window size
|
||||
- Schedules: cantidad de cron jobs configurados
|
||||
- Uptime del agente
|
||||
|
||||
- [ ] **4.2** Formatear como markdown legible con secciones
|
||||
|
||||
- [ ] **4.3** No exponer datos sensibles (tokens, API keys, paths internos, passwords)
|
||||
|
||||
### Fase 5: Estandarizar configs existentes
|
||||
|
||||
- [ ] **5.1** Definir convenciones estandar obligatorias para todo config.yaml:
|
||||
- `agent.version` siempre presente (semver)
|
||||
- `agent.tags` siempre presente (al menos un tag)
|
||||
- `personality.role` siempre presente
|
||||
- `personality.language` y `personality.languages_supported` siempre explicitos
|
||||
- `personality.communication` siempre presente (al menos formality y personality)
|
||||
- `personality.behavior` siempre con las 6 claves
|
||||
- `llm.tool_use` siempre explicito (enabled true/false, max_iterations)
|
||||
- `tools.memory` y `tools.knowledge` siempre presentes (enabled true/false)
|
||||
- `matrix.homeserver` y `matrix.encryption` siempre presentes
|
||||
- `observability.logging.level` siempre explicito
|
||||
- Si `skills.enabled: true`, al menos `skills.path` definido
|
||||
- Si `schedules` tiene entradas, cada una con `name` y `cron` validos
|
||||
|
||||
- [ ] **5.2** Actualizar `agents/assistant-bot/config.yaml` — anadir personalidad rica:
|
||||
```yaml
|
||||
personality:
|
||||
role: "asistente general"
|
||||
backstory: "Asistente polivalente, siempre listo para ayudar con cualquier tarea."
|
||||
expertise: [general, redaccion, resumen, consultas]
|
||||
limitations: []
|
||||
communication:
|
||||
formality: semiformal
|
||||
humor: subtle
|
||||
personality: empathetic
|
||||
response_style: conversational
|
||||
quirks: []
|
||||
avoid_topics: []
|
||||
catchphrases: []
|
||||
custom_directives: []
|
||||
# ... mas secciones nuevas (skills, shared_knowledge, etc.)
|
||||
```
|
||||
|
||||
- [ ] **5.3** Actualizar `agents/asistente-2/config.yaml` — idem, personalidad diferenciada
|
||||
|
||||
- [ ] **5.4** Validar que ambos agentes arrancan correctamente tras los cambios
|
||||
|
||||
### Fase 6: Integracion con nuevas capacidades en config
|
||||
|
||||
Las tasks 005 (cron), 016 (skills) y 018 (shared-knowledge) definen nuevos sistemas. El template debe incluir sus secciones de config para que nuevos agentes ya las tengan disponibles.
|
||||
|
||||
- [ ] **6.1** Anadir `SkillsCfg` al `AgentConfig` en schema.go (si no lo hizo la task 016):
|
||||
```go
|
||||
type SkillsCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Path string `yaml:"path"` // default: "skills/"
|
||||
Categories []string `yaml:"categories"` // filtro de categorias
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **6.2** Anadir `SharedKnowledgeCfg` al `ToolsCfg` en schema.go (si no lo hizo la task 018):
|
||||
```go
|
||||
type SharedKnowledgeCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Dir string `yaml:"dir"` // default: "knowledges"
|
||||
DBPath string `yaml:"db_path"` // default: "knowledges/data/knowledge.db"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **6.3** Verificar que `ScheduleCfg` soporta los 3 tipos de accion (send_message, run_tool, llm_prompt) — el schema actual ya los tiene pero validar completitud
|
||||
|
||||
- [ ] **6.4** Actualizar el template con las secciones de skills, shared_knowledge y schedules de ejemplo
|
||||
|
||||
### Fase 7: Documentacion y tooling
|
||||
|
||||
- [ ] **7.1** Anadir validacion en `internal/config/loader.go` que emita warnings si faltan secciones recomendadas (no bloquear, solo log):
|
||||
- personality.role vacio
|
||||
- personality.communication sin definir
|
||||
- skills.enabled true pero sin path
|
||||
- schedules con entradas sin name
|
||||
|
||||
- [ ] **7.2** Actualizar `.claude/rules/create_agent.md` para:
|
||||
- Referenciar el template como punto de partida
|
||||
- Incluir paso de definir personalidad rica
|
||||
- Incluir paso de decidir skills y shared-knowledge
|
||||
|
||||
- [ ] **7.3** Actualizar `docs/creating-agents.md` con la seccion de personalidades
|
||||
|
||||
- [ ] **7.4** Actualizar `CLAUDE.md` — agregar `SkillsCfg` y `SharedKnowledgeCfg` a la descripcion del schema
|
||||
|
||||
---
|
||||
|
||||
## Orden de ejecucion recomendado
|
||||
|
||||
1. **Fase 1** (sistema de personalidades) — tipos puros + BuildPersonalityPrompt + integracion runtime
|
||||
2. **Fase 2** (template) — config.yaml canonica con todo documentado
|
||||
3. **Fase 3** (ejemplos de personalidades) — PERSONALITIES.md como referencia
|
||||
4. **Fase 5** (estandarizar configs) — aplicar nuevos campos a agentes existentes
|
||||
5. **Fase 4** (info) — mostrar la metadata enriquecida
|
||||
6. **Fase 6** (nuevas capacidades) — integrar skills/knowledge/cron en schema si no existen
|
||||
7. **Fase 7** (docs) — cuando todo este estable
|
||||
|
||||
## Dependencias con otras tasks
|
||||
|
||||
| Task | Relacion |
|
||||
|------|----------|
|
||||
| 005 (cron) | El template incluye schedules de ejemplo. Si 005 no esta implementado, los schedules son solo config sin efecto. |
|
||||
| 016 (skills) | El template incluye `skills:` config. Si 016 no esta implementado, el runtime ignora la seccion. |
|
||||
| 018 (shared-knowledge) | El template incluye `shared_knowledge:` config. Si 018 no esta implementado, el runtime la ignora. |
|
||||
|
||||
Esta task puede ejecutarse **antes** que 005/016/018 — solo define el schema y template. Las otras tasks implementan la funcionalidad real.
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Personalidad en config, no en codigo**: la personalidad se define 100% en YAML y se transforma a prompt via `BuildPersonalityPrompt`. Cero logica de personalidad en Go.
|
||||
- **BuildPersonalityPrompt es pura**: vive en `pkg/personality/`, recibe datos, devuelve string. Sin side effects.
|
||||
- **Personalidad se concatena al system prompt**: no reemplaza el archivo `prompts/system.md`, se anade despues. El archivo define instrucciones base, la personalidad anade caracter.
|
||||
- **Template parseable**: el config.yaml del template es YAML valido con `template: true`. Sirve como test de que el schema esta completo.
|
||||
- **Backwards compatible**: los campos nuevos son opcionales. Agentes existentes sin `communication` o `role` siguen funcionando — `BuildPersonalityPrompt` genera un bloque vacio/minimo si no hay datos.
|
||||
- **PERSONALITIES.md como catalogo**: no son agentes reales, son perfiles de referencia. Al crear un agente nuevo, se copia un perfil y se ajusta.
|
||||
@@ -0,0 +1,211 @@
|
||||
# 015 — Soporte multi-plataforma: Telegram como segunda plataforma
|
||||
|
||||
## Objetivo
|
||||
|
||||
Desacoplar el runtime de agentes de Matrix e introducir abstracciones de plataforma que permitan conectar un mismo agente a multiples servicios de mensajeria. Implementar Telegram como primera plataforma adicional para validar el diseno.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Actualmente `agents/runtime.go` depende directamente de `*matrix.Client` y `*matrix.Listener`
|
||||
- `shell/effects/runner.go` ya define `MatrixSender` como interfaz, pero con nombre acoplado
|
||||
- `decision.MessageContext` es **generico** — no tiene nada de Matrix
|
||||
- Las reglas, LLM, tools y memoria son independientes de la plataforma
|
||||
- El acoplamiento esta en: runtime.go, effects/runner.go, listener, y algunos tools (matrix_send)
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Ninguno estricto. Se puede hacer de forma incremental sin romper Matrix.
|
||||
|
||||
---
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Abstracciones de plataforma en `pkg/platform/`
|
||||
|
||||
- [ ] **1.1** Crear `pkg/platform/types.go` con las interfaces puras:
|
||||
```go
|
||||
// Messenger envía mensajes a una plataforma de chat.
|
||||
type Messenger interface {
|
||||
SendText(ctx context.Context, roomID, text string) error
|
||||
SendMarkdown(ctx context.Context, roomID, markdown string) error
|
||||
SendReplyMarkdown(ctx context.Context, roomID, inReplyTo, markdown string) error
|
||||
SendTyping(ctx context.Context, roomID string, typing bool) error
|
||||
}
|
||||
|
||||
// EventSource escucha eventos de una plataforma y los entrega como MessageContext.
|
||||
type EventSource interface {
|
||||
Run(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Platform agrupa Messenger + EventSource para una plataforma concreta.
|
||||
type Platform interface {
|
||||
Messenger
|
||||
EventSource
|
||||
Name() string // "matrix", "telegram", "slack", etc.
|
||||
}
|
||||
```
|
||||
Nota: estas interfaces van en `pkg/` porque son tipos puros (no ejecutan I/O, solo los definen).
|
||||
|
||||
- [ ] **1.2** Definir `PlatformID` como prefijo para room IDs multi-plataforma:
|
||||
- Formato: `matrix:!abc123:server.com`, `telegram:chat_456`
|
||||
- Crear helpers `PrefixRoomID(platform, rawID) string` y `ParseRoomID(prefixed) (platform, rawID)`
|
||||
- Esto permite que la memoria y windows no mezclen contextos entre plataformas
|
||||
|
||||
### Fase 2: Adaptar shell/matrix/ a las interfaces
|
||||
|
||||
- [ ] **2.1** Verificar que `shell/matrix/Client` ya satisface `platform.Messenger` (deberia, con los metodos actuales). Anadir metodo `Name() string` que retorne `"matrix"`.
|
||||
|
||||
- [ ] **2.2** Refactorizar `shell/matrix/Listener` para que implemente `platform.EventSource`:
|
||||
- El `EventHandler` callback ya recibe `decision.MessageContext` — solo necesita ajustar la firma de `Run(ctx)` si difiere
|
||||
- Internamente sigue usando mautrix syncer, pero externamente expone la interfaz generica
|
||||
|
||||
- [ ] **2.3** Crear wrapper `shell/matrix/Platform` que componga Client + Listener e implemente `platform.Platform`
|
||||
|
||||
### Fase 3: Desacoplar runtime.go
|
||||
|
||||
- [ ] **3.1** Cambiar el campo `matrix *matrix.Client` en `Agent` struct por `messenger platform.Messenger`
|
||||
|
||||
- [ ] **3.2** Cambiar `listener *matrix.Listener` por `sources []platform.EventSource`
|
||||
|
||||
- [ ] **3.3** Actualizar `Run()` para arrancar multiples EventSources en goroutines:
|
||||
```go
|
||||
for _, src := range a.sources {
|
||||
go src.Run(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **3.4** Actualizar `handleEvent` para que no reciba `*event.Event` — actualmente solo usa `evt.RoomID` que ya esta en `MessageContext.RoomID`. Eliminar la dependencia de `mautrix/event`.
|
||||
|
||||
- [ ] **3.5** Actualizar todas las llamadas directas a `a.matrix.SendXxx()` y `a.matrix.SendTyping()` para usar `a.messenger.SendXxx()`. Puntos clave:
|
||||
- `handleEvent` — typing indicator, command replies, unknown command
|
||||
- `executeActions` — ya pasa por el runner, OK
|
||||
- `handleTaskEvent` — typing indicator, send reply
|
||||
- `runLLM` — tool call notices
|
||||
|
||||
- [ ] **3.6** Actualizar `shell/effects/runner.go`:
|
||||
- Renombrar interfaz `MatrixSender` a `Messenger` (o importar `platform.Messenger`)
|
||||
- El Runner ya recibe la interfaz, solo cambia el nombre
|
||||
|
||||
- [ ] **3.7** Actualizar `New()` constructor para recibir `[]platform.Platform` en vez de construir matrix.Client internamente. Mover la creacion de clientes de plataforma al launcher.
|
||||
|
||||
### Fase 4: Implementar shell/telegram/
|
||||
|
||||
- [ ] **4.1** Elegir libreria de Telegram Bot API para Go. Opciones:
|
||||
- (A) `github.com/go-telegram-bot-api/telegram-bot-api/v5` — la mas popular, estable
|
||||
- (B) `github.com/gotd/td` — cliente completo (MTProto), mas complejo
|
||||
- (C) HTTP directo contra Bot API — minimo, sin dependencias extra
|
||||
- **Recomendacion**: opcion (A) por madurez y simplicidad
|
||||
|
||||
- [ ] **4.2** Crear `shell/telegram/client.go`:
|
||||
- Struct `Client` con el bot API client interno
|
||||
- Constructor `New(cfg config.TelegramCfg) (*Client, error)`
|
||||
- Implementar `platform.Messenger`:
|
||||
- `SendText` — `tgbotapi.NewMessage(chatID, text)`
|
||||
- `SendMarkdown` — `tgbotapi.NewMessage` con `ParseMode: "MarkdownV2"`
|
||||
- `SendReplyMarkdown` — `ReplyToMessageID` en el message config
|
||||
- `SendTyping` — `tgbotapi.NewChatAction(chatID, "typing")`
|
||||
|
||||
- [ ] **4.3** Crear `shell/telegram/listener.go`:
|
||||
- Implementar `platform.EventSource`
|
||||
- Modo long-polling con `GetUpdatesChan()` (webhook es mas complejo y requiere dominio publico)
|
||||
- Convertir cada `tgbotapi.Update` a `decision.MessageContext`:
|
||||
- `SenderID` = user ID de Telegram (string)
|
||||
- `SenderName` = username o first_name
|
||||
- `RoomID` = `telegram:<chat_id>` (con prefijo de plataforma)
|
||||
- `Content` = texto del mensaje
|
||||
- `IsDirectMsg` = true si chat.Type == "private"
|
||||
- `IsMention` = true si el mensaje contiene @botname
|
||||
- `Command` = parsear si empieza con `!` (o `/` que es la convencion Telegram)
|
||||
- Llamar al mismo `handleEvent(ctx, msgCtx)` del Agent
|
||||
|
||||
- [ ] **4.4** Crear `shell/telegram/platform.go` que componga Client + Listener e implemente `platform.Platform`
|
||||
|
||||
### Fase 5: Configuracion
|
||||
|
||||
- [ ] **5.1** Anadir tipos de config en `internal/config/schema.go`:
|
||||
```yaml
|
||||
telegram:
|
||||
enabled: false
|
||||
bot_token_env: "TELEGRAM_TOKEN_BOT"
|
||||
allowed_chats: [] # lista de chat IDs permitidos (vacio = todos)
|
||||
command_prefix: "/" # convencion Telegram, ademas de "!"
|
||||
```
|
||||
Struct: `TelegramCfg` con campos `Enabled`, `BotTokenEnv`, `AllowedChats`, `CommandPrefix`
|
||||
|
||||
- [ ] **5.2** Anadir `TelegramCfg` al config principal del agente (junto a `MatrixCfg`)
|
||||
|
||||
- [ ] **5.3** Actualizar `internal/config/loader.go` para parsear la nueva seccion
|
||||
|
||||
- [ ] **5.4** Actualizar `cmd/launcher/main.go` para instanciar plataformas segun config:
|
||||
```go
|
||||
var platforms []platform.Platform
|
||||
if cfg.Matrix.Enabled { platforms = append(platforms, matrixPlatform) }
|
||||
if cfg.Telegram.Enabled { platforms = append(platforms, telegramPlatform) }
|
||||
```
|
||||
|
||||
- [ ] **5.5** Anadir `TELEGRAM_TOKEN_<BOT>` a `.env.example`
|
||||
|
||||
### Fase 6: Tool matrix_send → platform_send
|
||||
|
||||
- [ ] **6.1** Evaluar si `matrix_send` tool debe ser generico o especifico:
|
||||
- Opcion A: renombrar a `send_message` con parametro `platform` — mas flexible
|
||||
- Opcion B: mantener `matrix_send` y anadir `telegram_send` — mas simple
|
||||
- **Recomendacion**: opcion A a largo plazo, pero para esta task basta con que el LLM
|
||||
responda por la misma plataforma que recibio el mensaje (ya lo hace via `handleEvent` → runner)
|
||||
- `matrix_send` como tool explícita solo se usa para enviar a rooms arbitrarios; si no se necesita eso en Telegram, no hace falta `telegram_send` ahora
|
||||
|
||||
- [ ] **6.2** Si se opta por generalizar: crear `tools/send.go` con `NewPlatformSend(messenger platform.Messenger)` que el LLM pueda usar para enviar a cualquier plataforma
|
||||
|
||||
### Fase 7: Tests
|
||||
|
||||
- [ ] **7.1** Unit tests para `pkg/platform/types.go` — verificar que las interfaces compilan y los helpers de PlatformID funcionan
|
||||
|
||||
- [ ] **7.2** Unit tests para `shell/telegram/client.go` — mock del bot API, verificar conversion de mensajes
|
||||
|
||||
- [ ] **7.3** Unit tests para `shell/telegram/listener.go` — mock de updates, verificar conversion a MessageContext
|
||||
|
||||
- [ ] **7.4** Integration test: verificar que un Agent con dos plataformas (matrix mock + telegram mock) recibe y responde correctamente por ambas
|
||||
|
||||
- [ ] **7.5** Verificar que todos los agentes existentes siguen funcionando solo con Matrix (backward compat)
|
||||
|
||||
### Fase 8: Documentacion
|
||||
|
||||
- [ ] **8.1** Actualizar `CLAUDE.md` — anadir `shell/telegram/` a la estructura de directorios, actualizar diagrama de flujo
|
||||
|
||||
- [ ] **8.2** Actualizar `docs/creating-agents.md` con la seccion de configuracion multi-plataforma
|
||||
|
||||
- [ ] **8.3** Actualizar `.claude/rules/create_agent.md` para mencionar la seccion `telegram:` en config
|
||||
|
||||
- [ ] **8.4** Anadir a `README.md` la seccion de soporte Telegram
|
||||
|
||||
---
|
||||
|
||||
## Orden de ejecucion recomendado
|
||||
|
||||
1. **Fase 1** (interfaces) — base para todo lo demas
|
||||
2. **Fase 2** (adaptar matrix) — asegurar que Matrix sigue funcionando con las nuevas interfaces
|
||||
3. **Fase 3** (desacoplar runtime) — el refactor central; debe compilar y pasar tests con solo Matrix
|
||||
4. **Fase 5** (config) — preparar el config antes de implementar Telegram
|
||||
5. **Fase 4** (implementar telegram) — el codigo nuevo
|
||||
6. **Fase 6** (tools) — ajustar si es necesario
|
||||
7. **Fase 7** (tests) — validar todo
|
||||
8. **Fase 8** (docs) — ultima, cuando todo este estable
|
||||
|
||||
## Decisiones de diseno pendientes
|
||||
|
||||
- **Memoria compartida vs separada**: Si un usuario habla por Matrix y por Telegram, son windows separadas (por el prefijo de plataforma en roomID). Podria unificarse en el futuro con un "user identity" cross-platform, pero no es necesario ahora.
|
||||
- **Comandos `/` vs `!`**: Telegram usa `/` como convencion para comandos de bot. Soportar ambos prefijos (`/` y `!`) en el parser de comandos, configurable por plataforma.
|
||||
- **Webhooks vs long-polling**: Empezar con long-polling por simplicidad. Webhook requiere HTTPS publico y es una optimizacion posterior.
|
||||
- **Presence**: Matrix tiene presence (online/offline). Telegram no tiene equivalente nativo para bots. Abstraer como opcional en la interfaz.
|
||||
- **Reactions**: Matrix tiene reactions (`m.reaction`). Telegram tiene limitaciones. No incluir en `Messenger` por ahora; dejarlo como extension especifica de plataforma.
|
||||
|
||||
## Dependencias nuevas
|
||||
|
||||
```
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 # Telegram Bot API client
|
||||
```
|
||||
|
||||
## Riesgos
|
||||
|
||||
- El refactor de runtime.go (Fase 3) es el mas delicado — cambia el corazon del sistema. Hacer commits atomicos despues de cada sub-tarea.
|
||||
- Asegurar backward compatibility: un agente sin `telegram:` en su config debe funcionar exactamente como antes.
|
||||
@@ -0,0 +1,255 @@
|
||||
# 016 — Sistema de Skills para agentes
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear un sistema de skills reutilizables que los agentes puedan cargar y ejecutar. Las skills son paquetes de instrucciones, scripts y recursos que amplian las capacidades de un agente mas alla de las tools de function calling. Mientras las tools son funciones atomicas (clock, http_get, ssh_command), las skills son flujos completos de trabajo (deploy a produccion, analizar logs, generar reportes).
|
||||
|
||||
## Contexto
|
||||
|
||||
- Las **tools** (`tools/`) son funciones atomicas: reciben args, ejecutan, devuelven resultado. El LLM las invoca via function calling.
|
||||
- Las **skills** son paquetes de instrucciones + recursos que guian al agente para completar tareas complejas multi-paso. Son como "recetas" que el agente sigue.
|
||||
- Ejemplo: una tool es `ssh_command`. Una skill es "deploy-service" que usa ssh_command, http_get, y logica condicional para hacer un deploy completo.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Ninguno estricto. El sistema de tools existente sigue funcionando igual.
|
||||
|
||||
---
|
||||
|
||||
## Estructura de una skill
|
||||
|
||||
```
|
||||
skills/<categoria>/<skill-name>/
|
||||
├── SKILL.md ← obligatorio (frontmatter YAML + instrucciones markdown)
|
||||
├── LICENSE.txt ← opcional
|
||||
├── scripts/ ← opcional, codigo ejecutable (bash, python, etc.)
|
||||
├── references/ ← opcional, docs de referencia
|
||||
├── templates/ ← opcional, plantillas/assets
|
||||
└── assets/ ← opcional, fuentes, iconos, etc.
|
||||
```
|
||||
|
||||
### SKILL.md — formato
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: skill-name
|
||||
description: >
|
||||
Descripcion clara de que hace la skill y cuando debe activarse.
|
||||
Esta descripcion es el mecanismo principal de triggering.
|
||||
---
|
||||
|
||||
# Instrucciones
|
||||
|
||||
Cuerpo markdown con las instrucciones completas.
|
||||
Idealmente < 500 lineas.
|
||||
```
|
||||
|
||||
### Carga progresiva (3 niveles)
|
||||
|
||||
1. **Metadata** (name + description) — siempre en contexto (~100 palabras). El agente la lee para decidir si activar la skill.
|
||||
2. **Cuerpo del SKILL.md** — se carga cuando la skill se activa. Instrucciones principales.
|
||||
3. **Recursos bundled** (scripts/, references/, etc.) — se cargan bajo demanda. El SKILL.md indica cuando leer cada archivo.
|
||||
|
||||
### Carpetas opcionales
|
||||
|
||||
| Carpeta | Proposito |
|
||||
|---------|-----------|
|
||||
| `scripts/` | Codigo ejecutable que el agente corre (bash, python). Puede ejecutarlos sin cargarlos en contexto. |
|
||||
| `references/` | Documentacion extensa, leida solo cuando es relevante. Si > 300 lineas, agregar TOC al inicio. |
|
||||
| `templates/` | Plantillas que la skill usa como base para generar outputs. |
|
||||
| `assets/` | Archivos estaticos (fuentes, iconos, imagenes). |
|
||||
|
||||
---
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Estructura de directorios y skills iniciales
|
||||
|
||||
- [ ] **1.1** Crear la carpeta `skills/` en la raiz del proyecto con subcategorias:
|
||||
```
|
||||
skills/
|
||||
├── README.md ← documentacion del sistema de skills
|
||||
├── devops/ ← skills de operaciones y deploy
|
||||
├── analysis/ ← skills de analisis de datos/logs
|
||||
├── communication/ ← skills de comunicacion y notificaciones
|
||||
├── coding/ ← skills de desarrollo y code review
|
||||
└── system/ ← skills de administracion del sistema
|
||||
```
|
||||
|
||||
- [ ] **1.2** Crear skills iniciales de ejemplo:
|
||||
- `skills/devops/deploy-service/SKILL.md` — deploy de un servicio via SSH
|
||||
- `skills/analysis/log-analyzer/SKILL.md` — analisis de logs con patrones
|
||||
- `skills/communication/daily-report/SKILL.md` — generar y enviar reporte diario
|
||||
- `skills/system/health-check/SKILL.md` — verificar salud de servicios
|
||||
|
||||
### Fase 2: Tipos puros en `pkg/skills/`
|
||||
|
||||
- [ ] **2.1** Crear `pkg/skills/types.go` con los tipos puros:
|
||||
```go
|
||||
// SkillMeta es la metadata extraida del frontmatter YAML del SKILL.md.
|
||||
type SkillMeta struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Category string // derivado de la ruta del directorio
|
||||
}
|
||||
|
||||
// Skill es la representacion completa de una skill cargada.
|
||||
type Skill struct {
|
||||
Meta SkillMeta
|
||||
Instructions string // cuerpo markdown del SKILL.md
|
||||
BasePath string // ruta al directorio de la skill
|
||||
Scripts []string // rutas relativas a scripts/
|
||||
References []string // rutas relativas a references/
|
||||
Templates []string // rutas relativas a templates/
|
||||
}
|
||||
|
||||
// SkillMatch indica si una skill es relevante para un contexto dado.
|
||||
type SkillMatch struct {
|
||||
Skill SkillMeta
|
||||
Confidence float64 // 0.0 - 1.0
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **2.2** Crear `pkg/skills/match.go` — funcion pura que dado un mensaje y una lista de `SkillMeta`, retorna las skills mas relevantes:
|
||||
```go
|
||||
func Match(query string, skills []SkillMeta) []SkillMatch
|
||||
```
|
||||
Implementacion inicial: keyword matching simple contra name + description.
|
||||
|
||||
### Fase 3: Loader en `shell/skills/`
|
||||
|
||||
- [ ] **3.1** Crear `shell/skills/loader.go` — carga skills desde el filesystem:
|
||||
```go
|
||||
// Loader descubre y carga skills desde un directorio base.
|
||||
type Loader struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
func NewLoader(basePath string) *Loader
|
||||
func (l *Loader) LoadAll() ([]skills.Skill, error) // carga todas las skills
|
||||
func (l *Loader) LoadMeta() ([]skills.SkillMeta, error) // solo metadata (nivel 1)
|
||||
func (l *Loader) LoadSkill(name string) (*skills.Skill, error) // skill completa (nivel 2)
|
||||
func (l *Loader) ReadResource(skill, path string) (string, error) // recurso (nivel 3)
|
||||
```
|
||||
|
||||
- [ ] **3.2** Implementar parsing del SKILL.md:
|
||||
- Extraer frontmatter YAML entre `---`
|
||||
- Extraer cuerpo markdown
|
||||
- Listar archivos en subcarpetas opcionales
|
||||
|
||||
### Fase 4: Integracion con el runtime
|
||||
|
||||
- [ ] **4.1** Anadir `skillLoader *shellskills.Loader` al struct `Agent` en `agents/runtime.go`
|
||||
|
||||
- [ ] **4.2** Crear una tool `skill_search` en `tools/skills/` que permita al LLM buscar skills relevantes:
|
||||
```go
|
||||
// Def: name="skill_search", params=[{name: "query", type: "string"}]
|
||||
// Exec: usa el loader para buscar skills por relevancia
|
||||
```
|
||||
|
||||
- [ ] **4.3** Crear una tool `skill_load` en `tools/skills/` que cargue el contenido completo de una skill:
|
||||
```go
|
||||
// Def: name="skill_load", params=[{name: "skill_name", type: "string"}]
|
||||
// Exec: retorna las instrucciones completas del SKILL.md
|
||||
```
|
||||
|
||||
- [ ] **4.4** Crear una tool `skill_read_resource` para cargar recursos bajo demanda:
|
||||
```go
|
||||
// Def: name="skill_read_resource", params=[{name: "skill_name"}, {name: "path"}]
|
||||
// Exec: lee un archivo de scripts/, references/, templates/, o assets/
|
||||
```
|
||||
|
||||
- [ ] **4.5** Registrar las tools de skills en el builder de tools de `runtime.go`
|
||||
|
||||
- [ ] **4.6** Inyectar la lista de skills disponibles (nivel 1: metadata) en el system prompt del agente, para que sepa que skills tiene a disposicion.
|
||||
|
||||
### Fase 5: Configuracion
|
||||
|
||||
- [ ] **5.1** Anadir seccion `skills:` al config schema en `internal/config/schema.go`:
|
||||
```go
|
||||
type SkillsCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
SkillsPath string `yaml:"path"` // default: "skills/"
|
||||
Categories []string `yaml:"categories"` // filtro opcional de categorias
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **5.2** Anadir `SkillsCfg` al `AgentConfig` principal
|
||||
|
||||
- [ ] **5.3** Respetar el filtro de categorias: si un agente solo tiene `categories: [devops, system]`, no carga skills de `analysis/` o `communication/`
|
||||
|
||||
### Fase 6: Ejecucion de scripts
|
||||
|
||||
- [ ] **6.1** Evaluar como ejecutar scripts de skills de forma segura:
|
||||
- Los scripts viven en `skills/<cat>/<name>/scripts/`
|
||||
- El agente necesita permisos para ejecutarlos (similar a ssh_command)
|
||||
- Opcion A: ejecutar via `os/exec` con sandbox basico (allowlist de interpreters)
|
||||
- Opcion B: ejecutar via SSH contra localhost (reutiliza infra existente)
|
||||
- Opcion C: solo permitir bash scripts con validacion previa
|
||||
- **Recomendacion**: opcion A con allowlist configurable de interpreters
|
||||
|
||||
- [ ] **6.2** Crear `shell/skills/executor.go` para ejecutar scripts:
|
||||
```go
|
||||
type Executor struct {
|
||||
allowedInterpreters []string // ["bash", "python3", "sh"]
|
||||
timeout time.Duration
|
||||
}
|
||||
func (e *Executor) Run(ctx context.Context, scriptPath string, args []string) (string, error)
|
||||
```
|
||||
|
||||
- [ ] **6.3** Crear tool `skill_run_script` en `tools/skills/`:
|
||||
```go
|
||||
// Def: name="skill_run_script", params=[{name: "skill_name"}, {name: "script"}, {name: "args"}]
|
||||
// Exec: ejecuta un script de la skill con el executor
|
||||
```
|
||||
|
||||
### Fase 7: Tests
|
||||
|
||||
- [ ] **7.1** Unit tests para `pkg/skills/types.go` — verificar parsing de metadata
|
||||
- [ ] **7.2** Unit tests para `pkg/skills/match.go` — verificar matching de skills
|
||||
- [ ] **7.3** Unit tests para `shell/skills/loader.go` — verificar carga desde filesystem (con directorio temporal)
|
||||
- [ ] **7.4** Unit tests para `shell/skills/executor.go` — verificar ejecucion de scripts
|
||||
- [ ] **7.5** Integration test: un agente con skills habilitadas puede buscar, cargar y ejecutar una skill
|
||||
|
||||
### Fase 8: Documentacion
|
||||
|
||||
- [ ] **8.1** Crear `skills/README.md` con la guia completa del sistema de skills
|
||||
- [ ] **8.2** Actualizar `CLAUDE.md` — anadir `skills/`, `pkg/skills/`, `shell/skills/` a la estructura
|
||||
- [ ] **8.3** Crear `.claude/rules/create_skill.md` — regla para crear nuevas skills
|
||||
- [ ] **8.4** Actualizar `docs/creating-agents.md` con la seccion de skills
|
||||
|
||||
---
|
||||
|
||||
## Orden de ejecucion recomendado
|
||||
|
||||
1. **Fase 1** (estructura + skills de ejemplo) — valida el formato antes de escribir codigo
|
||||
2. **Fase 2** (tipos puros) — base para el loader y matching
|
||||
3. **Fase 3** (loader) — carga skills desde disco
|
||||
4. **Fase 5** (config) — permite habilitar/configurar skills por agente
|
||||
5. **Fase 4** (integracion runtime) — conecta skills al agente via tools
|
||||
6. **Fase 6** (ejecucion scripts) — opcional, solo si hay scripts
|
||||
7. **Fase 7** (tests) — validar todo
|
||||
8. **Fase 8** (docs) — cuando todo este estable
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Skills vs Tools**: las tools son atomicas (function calling). Las skills son flujos multi-paso que el agente sigue como instrucciones. Las skills USAN tools internamente.
|
||||
- **Carga progresiva**: no cargar todo en contexto — solo metadata siempre, instrucciones cuando se activa, recursos bajo demanda.
|
||||
- **Skills como carpeta en raiz**: viven en `skills/` (no en `pkg/` ni `shell/`) porque son contenido declarativo, no codigo Go. Similar a como `agents/` tiene configs y prompts.
|
||||
- **Subcategorias**: organizadas por dominio (devops, analysis, etc.) como los tools por funcion (clock, http, ssh, etc.).
|
||||
- **Seguridad de scripts**: los scripts de skills deben tener las mismas restricciones que ssh_command — allowlist de interpreters, timeout, sin acceso a secretos directos.
|
||||
|
||||
## Analogia con el patron del proyecto
|
||||
|
||||
```
|
||||
pkg/skills/ → PURE: tipos SkillMeta, Skill, SkillMatch + matching puro
|
||||
shell/skills/ → IMPURE: Loader (filesystem), Executor (os/exec)
|
||||
tools/skills/ → tools de function calling para que el LLM interactue con skills
|
||||
skills/ → contenido declarativo (SKILL.md + recursos)
|
||||
```
|
||||
|
||||
## Riesgos
|
||||
|
||||
- Inflar el contexto del LLM si se cargan muchas skills de golpe — mitigado por carga progresiva
|
||||
- Ejecucion de scripts arbitrarios — mitigado por allowlist de interpreters y timeout
|
||||
- Complejidad innecesaria si los agentes actuales no necesitan skills — empezar con 2-3 skills simples y validar
|
||||
@@ -0,0 +1,241 @@
|
||||
# 017 — MCP Client: consumir servidores MCP como tools del agente
|
||||
|
||||
## Objetivo
|
||||
|
||||
Permitir que los agentes se conecten a servidores MCP externos y expongan las tools de esos servidores como tools normales en su registry. Desde el punto de vista del LLM, una tool MCP es indistinguible de una tool nativa (ssh_command, http_get, etc.) — aparece en el function calling con su nombre, descripcion y parametros.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Ya existe `shell/protocols/mcp.go` que **expone** tools del agente como MCP server (server-side). Falta el **cliente** que consume tools de servidores MCP externos.
|
||||
- La dependencia `github.com/mark3labs/mcp-go v0.44.1` ya esta en go.mod. Incluye paquetes `client` y `mcp` con soporte para stdio y SSE/HTTP.
|
||||
- El config ya tiene `MCPToolCfg` con `Servers []MCPServerCfg` en `internal/config/schema.go`, pero solo soporta `url` — hay que extender para soportar transporte stdio (command + args).
|
||||
- El tool registry (`tools/Registry`) ya soporta registrar cualquier `tools.Tool` (Def + Exec).
|
||||
- El runtime (`agents/runtime.go:buildToolRegistry`) ya tiene el patron para registrar tools condicionalmente.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Ninguno estricto. La infraestructura de tools y config ya existe.
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
config.yaml (tools.mcp.servers)
|
||||
↓
|
||||
shell/mcp/client.go ← conecta a servidores MCP, descubre tools
|
||||
↓
|
||||
tools/mcptools/mcp.go ← wrappea cada tool MCP como tools.Tool
|
||||
↓
|
||||
agents/runtime.go ← registra en el Registry como cualquier otra tool
|
||||
↓
|
||||
LLM ve las tools MCP en function calling, las invoca normalmente
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
```
|
||||
pkg/ (nada nuevo) → no se necesitan tipos puros nuevos; tools.Def ya cubre
|
||||
shell/mcp/ → IMPURE: cliente MCP real (I/O, subprocesos, red)
|
||||
tools/mcptools/ → bridge: convierte MCP tool → tools.Tool
|
||||
```
|
||||
|
||||
## Transportes MCP soportados
|
||||
|
||||
| Transporte | Config | Descripcion |
|
||||
|-----------|--------|-------------|
|
||||
| **stdio** | `command` + `args` | Lanza un subproceso y se comunica via stdin/stdout. El mas comun (Claude Desktop, npx servers). |
|
||||
| **SSE/HTTP** | `url` | Se conecta a un servidor MCP remoto via HTTP con Server-Sent Events. |
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Extender config para stdio transport
|
||||
|
||||
- [ ] **1.1** Modificar `MCPServerCfg` en `internal/config/schema.go` para soportar ambos transportes:
|
||||
```go
|
||||
type MCPServerCfg struct {
|
||||
Name string `yaml:"name"` // nombre logico del servidor
|
||||
Transport string `yaml:"transport"` // "stdio" | "sse" (default: auto-detect)
|
||||
Command string `yaml:"command"` // stdio: comando a ejecutar
|
||||
Args []string `yaml:"args"` // stdio: argumentos del comando
|
||||
Env map[string]string `yaml:"env"` // stdio: variables de entorno extra
|
||||
URL string `yaml:"url"` // sse: URL del servidor
|
||||
Headers map[string]string `yaml:"headers"` // sse: headers HTTP extra (auth, etc.)
|
||||
Tools []string `yaml:"tools"` // filtro: solo exponer estas tools (vacio = todas)
|
||||
Prefix string `yaml:"prefix"` // prefijo para nombres de tools (evitar colisiones)
|
||||
Timeout time.Duration `yaml:"timeout"` // timeout por llamada (default: 30s)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **1.2** Validar que `Command` o `URL` este presente (al menos uno).
|
||||
|
||||
### Fase 2: MCP Client en `shell/mcp/`
|
||||
|
||||
- [ ] **2.1** Crear `shell/mcp/client.go` — wrapper sobre `mcp-go/client`:
|
||||
```go
|
||||
// Client conecta a un servidor MCP y descubre sus tools.
|
||||
type Client struct {
|
||||
name string
|
||||
mcpClient *client.StdioMCPClient // o SSEMCPClient
|
||||
tools []mcp.Tool // tools descubiertas
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewStdioClient(name, command string, args []string, env map[string]string, logger *slog.Logger) (*Client, error)
|
||||
func NewSSEClient(name, url string, headers map[string]string, logger *slog.Logger) (*Client, error)
|
||||
func (c *Client) Tools() []mcp.Tool // tools descubiertas
|
||||
func (c *Client) CallTool(ctx context.Context, name string, args map[string]any) (*mcp.CallToolResult, error)
|
||||
func (c *Client) Close() error
|
||||
```
|
||||
|
||||
- [ ] **2.2** Implementar `NewStdioClient`:
|
||||
- Crear `client.NewStdioMCPClient(command, env, args...)` (ver API de mcp-go)
|
||||
- Llamar `Initialize()` con info del agente
|
||||
- Llamar `ListTools()` para descubrir tools disponibles
|
||||
- Guardar la lista de tools
|
||||
|
||||
- [ ] **2.3** Implementar `NewSSEClient`:
|
||||
- Crear `client.NewSSEMCPClient(url, options...)`
|
||||
- Initialize + ListTools igual que stdio
|
||||
|
||||
- [ ] **2.4** Implementar `CallTool`:
|
||||
- Delegar a `mcpClient.CallTool(ctx, mcp.CallToolRequest{...})`
|
||||
- Extraer texto del resultado (manejar text y error results)
|
||||
|
||||
- [ ] **2.5** Implementar `Close`:
|
||||
- Cerrar el cliente MCP (mata el subproceso en stdio, cierra conexion en SSE)
|
||||
|
||||
### Fase 3: Bridge MCP → tools.Tool en `tools/mcptools/`
|
||||
|
||||
- [ ] **3.1** Crear `tools/mcptools/mcp.go` — convierte tools de un MCP server en `[]tools.Tool`:
|
||||
```go
|
||||
// FromMCPServer toma un shell/mcp.Client y genera tools.Tool para cada tool MCP.
|
||||
// prefix se antepone al nombre de la tool (ej: "brave_" → "brave_web_search").
|
||||
// filter limita que tools exponer (vacio = todas).
|
||||
func FromMCPServer(mcpClient *shellmcp.Client, prefix string, filter []string, timeout time.Duration) []tools.Tool
|
||||
```
|
||||
|
||||
- [ ] **3.2** Implementar conversion de `mcp.Tool` → `tools.Def`:
|
||||
- `Name` = prefix + tool.Name
|
||||
- `Description` = tool.Description
|
||||
- `Parameters` = convertir `tool.InputSchema` (JSON Schema) → `[]tools.Param`
|
||||
- JSON Schema properties → Param con name, type, description
|
||||
- JSON Schema required → Param.Required = true
|
||||
|
||||
- [ ] **3.3** Implementar el `ToolFunc` wrapper:
|
||||
- Recibe `args map[string]any`
|
||||
- Llama a `mcpClient.CallTool(ctx, originalName, args)` (sin prefix)
|
||||
- Convierte el resultado MCP a `tools.Result`
|
||||
|
||||
### Fase 4: Integracion en runtime
|
||||
|
||||
- [ ] **4.1** Crear `shell/mcp/manager.go` — gestiona multiples clientes MCP:
|
||||
```go
|
||||
// Manager inicializa y gestiona conexiones a multiples servidores MCP.
|
||||
type Manager struct {
|
||||
clients map[string]*Client // name → client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewManager(servers []config.MCPServerCfg, logger *slog.Logger) (*Manager, error)
|
||||
func (m *Manager) AllTools(reg *tools.Registry) // registra todas las tools en el registry
|
||||
func (m *Manager) Close() error // cierra todos los clientes
|
||||
```
|
||||
|
||||
- [ ] **4.2** Integrar en `agents/runtime.go`:
|
||||
- En `New()`: si `cfg.Tools.MCP.Enabled && len(cfg.Tools.MCP.Servers) > 0`, crear `mcp.NewManager(...)`
|
||||
- Llamar `manager.AllTools(toolReg)` para registrar las tools MCP en el registry
|
||||
- Guardar manager en `Agent` struct para cerrar en `Run()` defer
|
||||
- Las tools MCP aparecen automaticamente en el function calling del LLM
|
||||
|
||||
- [ ] **4.3** Anadir campo `mcpManager` al struct `Agent` y cerrar en `Run()`:
|
||||
```go
|
||||
type Agent struct {
|
||||
// ...existing fields...
|
||||
mcpManager *shellmcp.Manager // nil when MCP client is disabled
|
||||
}
|
||||
```
|
||||
|
||||
### Fase 5: Ejemplo de configuracion
|
||||
|
||||
- [ ] **5.1** Documentar ejemplo con servidor MCP stdio (ej: brave-search, filesystem):
|
||||
```yaml
|
||||
tools:
|
||||
mcp:
|
||||
enabled: true
|
||||
servers:
|
||||
- name: brave-search
|
||||
command: npx
|
||||
args: ["-y", "@anthropic/mcp-server-brave-search"]
|
||||
env:
|
||||
BRAVE_API_KEY: "${BRAVE_API_KEY}"
|
||||
prefix: "brave_"
|
||||
|
||||
- name: filesystem
|
||||
command: npx
|
||||
args: ["-y", "@anthropic/mcp-server-filesystem", "/home/data"]
|
||||
prefix: "fs_"
|
||||
|
||||
- name: remote-tools
|
||||
url: "http://localhost:8080/mcp"
|
||||
tools: ["search", "summarize"] # solo estas tools
|
||||
prefix: "remote_"
|
||||
```
|
||||
|
||||
- [ ] **5.2** Probar con al menos un servidor MCP real (brave-search o filesystem) en un agente de prueba.
|
||||
|
||||
### Fase 6: Tests
|
||||
|
||||
- [ ] **6.1** Unit tests para `tools/mcptools/mcp.go` — verificar conversion de schema MCP → tools.Def
|
||||
- [ ] **6.2** Unit tests para `shell/mcp/client.go` — mock del protocolo MCP (o test con echo server)
|
||||
- [ ] **6.3** Integration test: un agente con MCP habilitado lista tools MCP en su registry
|
||||
|
||||
### Fase 7: Cleanup y docs
|
||||
|
||||
- [ ] **7.1** Actualizar `CLAUDE.md` — anadir `shell/mcp/`, `tools/mcptools/` a la estructura
|
||||
- [ ] **7.2** Actualizar `.claude/rules/create_tool.md` si es necesario — mencionar que tools MCP se auto-registran
|
||||
- [ ] **7.3** Mover o refactorizar `shell/protocols/mcp.go` (MCP server) a `shell/mcp/server.go` para colocarlo junto al client
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de flujo completo
|
||||
|
||||
```
|
||||
1. Agente arranca, config tiene tools.mcp.servers con brave-search (stdio)
|
||||
|
||||
2. runtime.go → mcp.NewManager() → lanza `npx -y @anthropic/mcp-server-brave-search`
|
||||
→ Initialize → ListTools → descubre: web_search, local_search
|
||||
|
||||
3. mcptools.FromMCPServer() convierte:
|
||||
- mcp.Tool{name: "web_search", ...} → tools.Tool{Def: {Name: "brave_web_search", ...}, Exec: wrapper}
|
||||
- mcp.Tool{name: "local_search", ...} → tools.Tool{Def: {Name: "brave_local_search", ...}, Exec: wrapper}
|
||||
|
||||
4. Se registran en el toolReg → aparecen en ToLLMSpecs()
|
||||
|
||||
5. Usuario pregunta: "busca noticias sobre Go 1.23"
|
||||
→ LLM ve brave_web_search en sus tools → genera tool_call
|
||||
→ runtime ejecuta → wrapper llama mcpClient.CallTool("web_search", args)
|
||||
→ resultado vuelve al LLM → genera respuesta final
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Prefix por servidor**: evita colisiones de nombres entre servidores MCP que tengan tools con el mismo nombre. Configurable por servidor.
|
||||
- **Filter de tools**: permite exponer solo un subset de tools de un servidor MCP (seguridad + reducir contexto del LLM).
|
||||
- **Manager pattern**: centraliza lifecycle de multiples clientes MCP. Similar a como el bus manager gestiona multiples agentes.
|
||||
- **Stdio como transporte principal**: es el estandar de facto en MCP. Los servidores mas populares (brave, filesystem, github, etc.) usan stdio.
|
||||
- **Auto-discovery**: las tools se descubren automaticamente via `ListTools()`. No hace falta declararlas manualmente.
|
||||
- **Sin tipos puros nuevos**: `tools.Def` y `tools.Param` ya cubren la especificacion de una tool. No se necesita nada en `pkg/`.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Subprocesos zombie**: si el agente crashea, los procesos MCP stdio pueden quedar huerfanos. Mitigar con process groups y cleanup en `Close()`.
|
||||
- **Latencia de inicio**: `npx -y` descarga paquetes la primera vez. Puede tardar. Considerar cache o pre-instalacion.
|
||||
- **Schema complejo**: algunos MCP servers tienen input schemas con nested objects/arrays. La conversion a `tools.Param` debe manejar esto (al menos `object` y `array` como tipos).
|
||||
- **Seguridad**: un servidor MCP malicioso podria exponer tools daninas. El filtro de tools y el prefix ayudan, pero la confianza es del operador.
|
||||
- **Timeout**: llamadas a MCP servers externos pueden ser lentas. Timeout configurable por servidor.
|
||||
|
||||
## Dependencias
|
||||
|
||||
- `github.com/mark3labs/mcp-go v0.44.1` — ya en go.mod, incluye `client` package
|
||||
- No se necesitan dependencias nuevas
|
||||
@@ -0,0 +1,161 @@
|
||||
# 018 — Shared Knowledge: base de conocimiento compartida entre agentes
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear un sistema de conocimiento compartido (`knowledges/` en la raiz del proyecto) donde multiples agentes pueden leer, escribir y buscar documentos en comun. Esto permite colaboracion entre agentes: uno puede registrar informacion que otros consultan.
|
||||
|
||||
## Contexto
|
||||
|
||||
- Cada agente ya tiene su **knowledge privado** en `agents/<id>/knowledge/` con SQLite FTS5 index (`shell/knowledge/store.go`).
|
||||
- Los tipos puros ya existen: `pkg/knowledge.Document`, `SearchResult`, `Store` interface.
|
||||
- Las tools de knowledge ya existen: `tools/knowledgetools/` (search, read, write, list).
|
||||
- El `FileStore` en `shell/knowledge/` ya implementa todo el CRUD + FTS5.
|
||||
- Lo que falta es una **instancia compartida** de `FileStore` apuntando a `knowledges/` con tools dedicadas que multiples agentes puedan usar.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
knowledges/ ← carpeta raiz, documentos .md compartidos
|
||||
knowledges/data/knowledge.db ← SQLite FTS5 index compartido (en .gitignore)
|
||||
|
||||
pkg/knowledge/ ← sin cambios, los tipos puros ya cubren
|
||||
shell/knowledge/store.go ← sin cambios, FileStore ya es reutilizable
|
||||
tools/knowledgetools/shared.go ← NEW: tools prefijadas shared_knowledge_*
|
||||
agents/runtime.go ← instanciar shared store + registrar tools
|
||||
internal/config/schema.go ← config para habilitar shared knowledge
|
||||
```
|
||||
|
||||
### Patron pure core / impure shell
|
||||
|
||||
- `pkg/` — sin cambios, `knowledge.Store` interface ya sirve
|
||||
- `shell/knowledge/` — sin cambios, `FileStore` ya funciona con cualquier directorio
|
||||
- `tools/knowledgetools/` — nuevas tools que wrappean el store compartido
|
||||
- `agents/runtime.go` — composicion: crea shared store y registra tools
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Config
|
||||
|
||||
- [ ] **1.1** Agregar seccion `shared_knowledge` al config en `internal/config/schema.go`:
|
||||
```go
|
||||
type SharedKnowledgeCfg struct {
|
||||
Enabled bool `yaml:"enabled"` // default false
|
||||
Dir string `yaml:"dir"` // default "knowledges"
|
||||
DBPath string `yaml:"db_path"` // default "knowledges/data/knowledge.db"
|
||||
}
|
||||
```
|
||||
- [ ] **1.2** Agregar campo `SharedKnowledge SharedKnowledgeCfg` al `ToolsCfg` (o al `AgentConfig` directamente).
|
||||
|
||||
### Fase 2: Tools compartidas en `tools/knowledgetools/`
|
||||
|
||||
- [ ] **2.1** Crear `tools/knowledgetools/shared.go` con tools prefijadas `shared_knowledge_*`:
|
||||
- `shared_knowledge_search` — buscar en la base compartida
|
||||
- `shared_knowledge_read` — leer un documento compartido por slug
|
||||
- `shared_knowledge_write` — crear/actualizar un documento compartido
|
||||
- `shared_knowledge_list` — listar todos los documentos compartidos
|
||||
- Reutilizar `KnowledgeStore` interface y la misma logica de las tools privadas pero con nombres y descripciones que indican "shared across all agents"
|
||||
|
||||
- [ ] **2.2** Cada tool debe incluir en su descripcion que es conocimiento **compartido** entre agentes:
|
||||
```
|
||||
"Search the shared knowledge base accessible by all agents. Use this to find information other agents have recorded."
|
||||
```
|
||||
|
||||
- [ ] **2.3** Funcion constructora:
|
||||
```go
|
||||
// NewSharedKnowledgeTools creates all shared knowledge tools backed by the given store.
|
||||
func NewSharedKnowledgeTools(store KnowledgeStore) []tools.Tool
|
||||
```
|
||||
|
||||
### Fase 3: Integracion en runtime
|
||||
|
||||
- [ ] **3.1** En `agents/runtime.go`, si `cfg.Tools.SharedKnowledge.Enabled` (o donde se ponga en config):
|
||||
- Crear un `shellknowledge.New(dir, dbPath, logger)` con la ruta compartida
|
||||
- Llamar `Sync(ctx)` al arrancar
|
||||
- Registrar las tools de `NewSharedKnowledgeTools(sharedStore)` en el registry
|
||||
- Guardar referencia para cerrar en defer
|
||||
|
||||
- [ ] **3.2** El shared store debe ser **una instancia por agente** (cada proceso abre su propia conexion SQLite al mismo archivo DB). SQLite soporta lecturas concurrentes y escrituras serializadas con WAL mode.
|
||||
|
||||
- [ ] **3.3** Habilitar WAL mode en el shared store para mejor concurrencia entre procesos:
|
||||
```go
|
||||
db.Exec("PRAGMA journal_mode=WAL")
|
||||
```
|
||||
Esto puede ir en `shell/knowledge/store.go` `New()` para beneficiar tambien al store privado.
|
||||
|
||||
### Fase 4: Carpeta `knowledges/`
|
||||
|
||||
- [ ] **4.1** Crear `knowledges/` en la raiz del proyecto con un `README.md` explicando su proposito.
|
||||
- [ ] **4.2** Agregar `knowledges/data/` a `.gitignore` (la DB no se commitea, los .md si).
|
||||
|
||||
### Fase 5: Coexistencia con knowledge privado
|
||||
|
||||
- [ ] **5.1** Un agente puede tener **ambos** habilitados: knowledge privado (`agents/<id>/knowledge/`) y shared (`knowledges/`). Las tools se distinguen por nombre:
|
||||
- `knowledge_search` / `knowledge_read` / `knowledge_write` / `knowledge_list` → privado
|
||||
- `shared_knowledge_search` / `shared_knowledge_read` / `shared_knowledge_write` / `shared_knowledge_list` → compartido
|
||||
|
||||
- [ ] **5.2** Documentar en el system prompt de los agentes la diferencia:
|
||||
- Knowledge privado: "tu base de conocimiento personal, solo tu puedes ver"
|
||||
- Knowledge compartido: "base compartida entre todos los agentes, usa para colaborar"
|
||||
|
||||
### Fase 6: Tests
|
||||
|
||||
- [ ] **6.1** Test de `NewSharedKnowledgeTools` — verificar que genera 4 tools con nombres `shared_knowledge_*`.
|
||||
- [ ] **6.2** Test de integracion: dos stores apuntando al mismo directorio pueden leer lo que el otro escribe (simula dos agentes).
|
||||
- [ ] **6.3** Test de concurrencia basico con WAL mode.
|
||||
|
||||
### Fase 7: Cleanup y docs
|
||||
|
||||
- [ ] **7.1** Actualizar `CLAUDE.md` — agregar `knowledges/` a la estructura de directorios.
|
||||
- [ ] **7.2** Actualizar `.gitignore` con `knowledges/data/`.
|
||||
- [ ] **7.3** Ejemplo de config habilitando shared knowledge en un agente existente.
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de config
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
knowledge:
|
||||
enabled: true # knowledge privado del agente
|
||||
dir: "knowledge" # relativo a agents/<id>/
|
||||
|
||||
shared_knowledge:
|
||||
enabled: true # knowledge compartido
|
||||
dir: "knowledges" # relativo a la raiz del proyecto
|
||||
db_path: "knowledges/data/knowledge.db"
|
||||
```
|
||||
|
||||
## Ejemplo de flujo
|
||||
|
||||
```
|
||||
1. agente-A recibe: "investiga X y guarda lo que encuentres"
|
||||
→ LLM usa shared_knowledge_write(slug: "investigacion-x", content: "...")
|
||||
→ Se escribe knowledges/investigacion-x.md + actualiza FTS5
|
||||
|
||||
2. agente-B recibe: "que sabemos sobre X?"
|
||||
→ LLM usa shared_knowledge_search(query: "X")
|
||||
→ Encuentra el documento que escribio agente-A
|
||||
→ shared_knowledge_read(slug: "investigacion-x")
|
||||
→ Responde con la informacion
|
||||
|
||||
3. Agentes colaboran acumulando conocimiento en la misma base
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Reusar FileStore**: no crear un store nuevo. `shell/knowledge.FileStore` ya tiene todo (CRUD, FTS5, Sync). Solo se instancia con una ruta diferente.
|
||||
- **WAL mode**: permite que multiples procesos lean/escriban concurrentemente. Es la forma estandar de compartir SQLite entre procesos.
|
||||
- **Prefix `shared_knowledge_`**: diferencia claramente las tools compartidas de las privadas. El LLM sabe cual usar segun contexto.
|
||||
- **Los .md se commitean, la DB no**: los documentos compartidos forman parte del repo (versionados). La DB FTS5 se reconstruye con `Sync()` al arrancar.
|
||||
- **Sin control de acceso por agente**: cualquier agente con shared_knowledge habilitado puede leer y escribir. Simplicidad primero; RBAC se puede agregar despues si hace falta.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Knowledge privado ya funcional (pkg/knowledge, shell/knowledge, tools/knowledgetools) — ya implementado.
|
||||
- No tiene dependencias externas nuevas.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Contention en escritura**: si muchos agentes escriben simultaneamente, SQLite serializa las escrituras. Con WAL mode esto es manejable para el volumen esperado.
|
||||
- **Sync al arrancar**: si hay muchos documentos, el Sync inicial puede tardar. No deberia ser problema con volumenes pequenos.
|
||||
- **Conflictos de slug**: dos agentes podrian sobreescribir el mismo documento. Esto es intencional (ultimo gana), pero el LLM debe ser consciente via el system prompt.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Issues — Extensiones pendientes
|
||||
|
||||
Cada archivo describe un feature a implementar con su diseño tecnico, archivos
|
||||
afectados y notas de implementacion.
|
||||
|
||||
| # | Feature | Archivo | Estado |
|
||||
|----|------------------------------|------------------------------------------------------------------|------------|
|
||||
| 1 | Herramientas para los bots | [001-bot-tools.md](completed/001-bot-tools.md) | completado |
|
||||
| 2 | Memoria para los bots | [002-bot-memory.md](completed/002-bot-memory.md) | completado |
|
||||
| 3 | Interaccion entre bots | [003-bot-interaction.md](completed/003-bot-interaction.md) | completado |
|
||||
| 4 | Fotos de perfil | [004-bot-avatar.md](completed/004-bot-avatar.md) | completado |
|
||||
| 5 | Cron scheduler | [005-bot-cron.md](005-bot-cron.md) | pendiente |
|
||||
| 6 | Añadir Claude provider | [006-añadir-claude-p.md](completed/006-añadir-claude-p.md) | completado |
|
||||
| 7 | Logs mejorados | [007-logs-mejorados.md](completed/007-logs-mejorados.md) | completado |
|
||||
| 8 | Knowledge por agente | [008-knowledge_por_agente.md](completed/008-knowledge_por_agente.md) | completado |
|
||||
| 9 | Command system | [009-command_system.md](completed/009-command_system.md) | completado |
|
||||
| 10 | Access control | [010-access-control.md](010-access-control.md) | pendiente |
|
||||
| 11 | Markdown rendering | [011-markdown-rendering.md](completed/011-markdown-rendering.md) | completado |
|
||||
| 12 | Threads | [012-threads.md](012-threads.md) | pendiente |
|
||||
| 13 | Hot reload | [013-hot-reload.md](013-hot-reload.md) | pendiente |
|
||||
| 14 | Template agent standardize | [014-template-agent-standardize.md](014-template-agent-standardize.md) | pendiente |
|
||||
| 15 | Multi-platform Telegram | [015-multi-platform-telegram.md](015-multi-platform-telegram.md) | pendiente |
|
||||
| 16 | Skills system | [016-skills-system.md](016-skills-system.md) | pendiente |
|
||||
| 17 | MCP client tools | [017-mcp-client-tools.md](017-mcp-client-tools.md) | pendiente |
|
||||
| 18 | Shared knowledge | [018-shared-knowledge.md](018-shared-knowledge.md) | pendiente |
|
||||
@@ -0,0 +1,52 @@
|
||||
# Plan: Herramientas para los bots
|
||||
|
||||
## Objetivo
|
||||
Permitir que los bots ejecuten herramientas reales (funciones Go) como respuesta a
|
||||
decisiones del LLM — patrón function calling / tool use.
|
||||
|
||||
## Estado: COMPLETADO
|
||||
|
||||
---
|
||||
|
||||
## Diseño
|
||||
|
||||
### Capa pura (`pkg/tools/`)
|
||||
- Definir `ToolSpec` con nombre, descripción y esquema JSON de parámetros
|
||||
- Definir `ToolCallAction` en `pkg/decision/` — acción pura que contiene
|
||||
`ToolName string` y `Args map[string]any`
|
||||
- El motor de reglas puede emitir `ToolCallAction` como cualquier otra acción
|
||||
|
||||
### Capa shell (`shell/tools/`)
|
||||
- `Executor` que mapea nombre → función Go real
|
||||
- Ejecuta la herramienta y devuelve `ToolResult{Output string, Err error}`
|
||||
- El Runner de `shell/effects/` llama al Executor cuando recibe `ToolCallAction`
|
||||
|
||||
### Integración LLM
|
||||
- `shell/llm/anthropic.go` y `openai.go` ya soportan tool_use / function_calling
|
||||
- Mapear `[]ToolSpec` al formato nativo de cada proveedor
|
||||
- Parsear la respuesta del LLM para extraer llamadas a herramientas
|
||||
|
||||
### Herramientas iniciales a implementar
|
||||
| Herramienta | Descripción | Shell |
|
||||
|-----------------|-------------------------------------|-------------------|
|
||||
| `http_get` | GET a una URL, devuelve body | `shell/tools/` |
|
||||
| `http_post` | POST JSON a una URL | `shell/tools/` |
|
||||
| `ssh_command` | Ejecutar comando remoto por SSH | `shell/ssh/` |
|
||||
| `read_file` | Leer archivo local | `shell/tools/` |
|
||||
| `matrix_send` | Enviar mensaje a una sala Matrix | `shell/matrix/` |
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
- `pkg/tools/spec.go` — ToolSpec, ToolResult
|
||||
- `pkg/decision/actions.go` — añadir ToolCallAction
|
||||
- `shell/tools/executor.go` — registro y ejecución de herramientas
|
||||
- `shell/effects/runner.go` — manejar ToolCallAction
|
||||
- `shell/llm/anthropic.go` — emitir tools en el request, parsear tool_use blocks
|
||||
- `shell/llm/openai.go` — idem para function_calling
|
||||
- `agents/<id>/agent.go` — registrar herramientas por agente
|
||||
|
||||
## Notas
|
||||
- Las herramientas se declaran en `pkg/` (pure spec) pero se implementan en `shell/`
|
||||
- Un agente solo tiene acceso a las herramientas declaradas en su config
|
||||
- Respetar `security.allowed_tools` del config YAML
|
||||
@@ -0,0 +1,95 @@
|
||||
# Plan: Memoria para los bots
|
||||
|
||||
## Objetivo
|
||||
Que cada bot recuerde conversaciones anteriores, hechos importantes sobre usuarios
|
||||
y contexto de salas. Memoria a corto plazo (ventana de conversación) y largo plazo
|
||||
(SQLite persistente).
|
||||
|
||||
## Estado: completado ✓
|
||||
|
||||
---
|
||||
|
||||
## Tipos de memoria
|
||||
|
||||
### 1. Memoria de conversación (corto plazo)
|
||||
- Ventana deslizante de `N` mensajes por room
|
||||
- Se pasa como historial al LLM en cada llamada
|
||||
- Vive en RAM; se pierde al reiniciar (aceptable)
|
||||
|
||||
### 2. Memoria episódica (largo plazo)
|
||||
- Hechos extraídos de conversaciones: nombre del usuario, preferencias, eventos
|
||||
- Guardados en SQLite (`agents/<id>/data/memory.db`)
|
||||
- El LLM puede leer y escribir hechos mediante herramientas (`remember`, `recall`)
|
||||
|
||||
---
|
||||
|
||||
## Diseño capa pura (`pkg/memory/`)
|
||||
|
||||
```go
|
||||
// Tipos puros — sin I/O
|
||||
type Message struct {
|
||||
Role string // "user" | "assistant"
|
||||
Content string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type Fact struct {
|
||||
Subject string
|
||||
Key string
|
||||
Value string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Ventana de conversación
|
||||
type Window struct {
|
||||
RoomID string
|
||||
Messages []Message
|
||||
MaxSize int
|
||||
}
|
||||
|
||||
func (w Window) Append(m Message) Window { ... } // pura
|
||||
func (w Window) ToLLMMessages() []llm.Message { ... } // pura
|
||||
```
|
||||
|
||||
## Diseño capa shell (`shell/memory/`)
|
||||
|
||||
```go
|
||||
// Acceso a SQLite — impuro
|
||||
type Store interface {
|
||||
SaveFact(ctx, agentID, fact) error
|
||||
GetFacts(ctx, agentID, subject) ([]Fact, error)
|
||||
GetHistory(ctx, agentID, roomID, limit) ([]Message, error)
|
||||
AppendMessage(ctx, agentID, roomID, msg) error
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Herramientas LLM para memoria
|
||||
- `remember(subject, key, value)` — guardar un hecho
|
||||
- `recall(subject, key)` — recuperar hechos sobre alguien/algo
|
||||
- `forget(subject, key)` — borrar un hecho
|
||||
|
||||
---
|
||||
|
||||
## Integración con el flujo actual
|
||||
1. `agents/runtime.go` mantiene un `map[roomID]memory.Window` en RAM
|
||||
2. Antes de llamar al LLM, inyectar historial de la ventana al request
|
||||
3. Después de la respuesta, hacer `Append` con el mensaje del bot
|
||||
4. Las herramientas `remember`/`recall` van al `Store` SQLite
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
- `pkg/memory/types.go` — Message, Fact, Window (puros)
|
||||
- `pkg/memory/window.go` — operaciones sobre Window (puras)
|
||||
- `shell/memory/sqlite_store.go` — Store SQLite
|
||||
- `shell/memory/migrations/001_init.sql` — schema
|
||||
- `agents/runtime.go` — inyectar historial antes del LLM call
|
||||
- `agents/<id>/agent.go` — registrar herramientas remember/recall
|
||||
|
||||
## Notas
|
||||
- Schema SQLite: tabla `facts(agent_id, subject, key, value, updated_at)`,
|
||||
tabla `messages(agent_id, room_id, role, content, created_at)`
|
||||
- El tamaño de la ventana se configura en `storage.max_context_messages`
|
||||
(añadir al schema de config)
|
||||
@@ -0,0 +1,275 @@
|
||||
# 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: Completo
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,69 @@
|
||||
# Plan: Editar fotos de perfil de los bots
|
||||
|
||||
## Objetivo
|
||||
Poder actualizar el avatar (foto de perfil) y el display name de cada bot en Matrix
|
||||
desde la CLI (`agentctl`) o desde un dev-script.
|
||||
|
||||
## Estado: COMPLETADO
|
||||
|
||||
---
|
||||
|
||||
## Cómo funciona en Matrix
|
||||
- Endpoint: `PUT /_matrix/client/v3/profile/{userId}/avatar_url`
|
||||
- Body: `{ "avatar_url": "mxc://..." }` — URI de contenido subido al Media repo
|
||||
- Para subir una imagen: `POST /_matrix/media/v3/upload` con el body binario
|
||||
y `Content-Type` de la imagen
|
||||
- También se puede cambiar el display name:
|
||||
`PUT /_matrix/client/v3/profile/{userId}/displayname`
|
||||
|
||||
La secuencia es:
|
||||
1. Subir imagen → obtener `mxc://server/mediaID`
|
||||
2. Establecer `avatar_url` en el perfil con esa URI
|
||||
|
||||
---
|
||||
|
||||
## Diseño
|
||||
|
||||
### CLI: `agentctl avatar <agent-id> <image-path>`
|
||||
Nuevo subcomando en `cmd/agentctl/`:
|
||||
```
|
||||
agentctl avatar assistant-bot /path/to/photo.png
|
||||
agentctl displayname assistant-bot "Assistant Bot"
|
||||
```
|
||||
|
||||
### Shell: `shell/matrix/profile.go`
|
||||
```go
|
||||
// UploadMedia sube un archivo y devuelve la mxc:// URI
|
||||
func UploadMedia(ctx, client, filePath string) (mxcURI string, err error)
|
||||
|
||||
// SetAvatar establece avatar_url en el perfil del bot
|
||||
func SetAvatar(ctx, client, mxcURI string) error
|
||||
|
||||
// SetDisplayName cambia el displayname
|
||||
func SetDisplayName(ctx, client, name string) error
|
||||
```
|
||||
|
||||
Usa el cliente `mautrix.Client` ya existente en `shell/matrix/client.go`.
|
||||
|
||||
### Dev-script: `dev-scripts/avatar.sh`
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Uso: ./dev-scripts/avatar.sh <agent-id> <image-path>
|
||||
./bin/agentctl avatar "$1" "$2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
- `shell/matrix/profile.go` — UploadMedia, SetAvatar, SetDisplayName
|
||||
- `cmd/agentctl/avatar.go` — subcomando `avatar` y `displayname`
|
||||
- `cmd/agentctl/main.go` — registrar los nuevos subcomandos en Cobra
|
||||
- `dev-scripts/avatar.sh` — wrapper convenience
|
||||
|
||||
## Notas
|
||||
- El token del bot necesita permiso de escritura en su propio perfil (normal por defecto)
|
||||
- Formatos soportados: PNG, JPG, WebP — Matrix los acepta todos
|
||||
- mautrix-go tiene métodos `client.UploadMedia()` y `client.SetAvatarURL()`;
|
||||
usar esos directamente para evitar HTTP manual
|
||||
- El comando debe cargar el token del bot desde las env vars (`MATRIX_TOKEN_<BOT>`)
|
||||
igual que hace `cmd/launcher/`
|
||||
@@ -0,0 +1,317 @@
|
||||
# Plan: Claude Code (`claude -p`) como proveedor LLM de la shell
|
||||
|
||||
## Objetivo
|
||||
|
||||
Que `claude -p` sea un backend LLM más dentro de `shell/llm/`, al mismo nivel que la API HTTP de Anthropic u otros proveedores. Los agentes no saben si su "modelo" es una llamada REST o un subproceso de Claude Code — simplemente envían un `CompletionRequest` y reciben un `CompletionResult`.
|
||||
|
||||
## Estado: Completado
|
||||
|
||||
---
|
||||
|
||||
## Casos de uso
|
||||
|
||||
- Configurar un agente con `model: claude-code` y que todas sus respuestas pasen por `claude -p`
|
||||
- Un agente usa Claude Code como modelo principal, obteniendo capacidades agenticas (bash, file I/O, git) gratis sin implementarlas en nuestra shell
|
||||
- Agentes que necesitan razonar sobre un repo completo delegan al modelo `claude-code` que ya tiene contexto del worktree
|
||||
- Migrar agentes entre proveedores cambiando solo el campo `model` en YAML
|
||||
- Combinar modelos: un agente usa `sonnet` para respuestas rápidas y `claude-code` para tareas que requieren ejecución
|
||||
|
||||
---
|
||||
|
||||
## Diseño
|
||||
|
||||
### Config YAML — el agente simplemente elige su modelo
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
- name: "dev-bot"
|
||||
model: "claude-code" # ← usa claude -p como backend LLM
|
||||
model_config:
|
||||
binary: "claude" # path al binario (default: "claude")
|
||||
max_turns: 10 # turnos agenticos internos de claude -p
|
||||
timeout: "5m"
|
||||
allowed_tools: # tools que claude -p puede usar internamente
|
||||
- "bash"
|
||||
- "read_file"
|
||||
- "write_file"
|
||||
- "git"
|
||||
working_dir: "{{worktree}}"
|
||||
system_prompt_file: "prompts/dev-bot-system.md"
|
||||
|
||||
- name: "chat-bot"
|
||||
model: "sonnet" # ← usa API HTTP normal
|
||||
model_config:
|
||||
api_key_env: "ANTHROPIC_API_KEY"
|
||||
```
|
||||
|
||||
El campo `model` determina qué proveedor de `shell/llm/` se instancia. La `model_config` es específica de cada proveedor.
|
||||
|
||||
---
|
||||
|
||||
### Interfaz pura (core) — sin cambios
|
||||
|
||||
La interfaz del core no cambia. El contrato ya existe:
|
||||
|
||||
```go
|
||||
// core/llm/types.go — esto ya existe o debería existir
|
||||
|
||||
type CompletionRequest struct {
|
||||
SystemPrompt string
|
||||
Messages []Message
|
||||
Temperature float64
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
type CompletionResult struct {
|
||||
Content string
|
||||
TokensUsed TokenUsage
|
||||
FinishReason string // "stop", "max_turns", "timeout", "error"
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type TokenUsage struct {
|
||||
Input int
|
||||
Output int
|
||||
}
|
||||
```
|
||||
|
||||
El core solo conoce esta interfaz. No sabe si detrás hay HTTP, un subproceso o una paloma mensajera.
|
||||
|
||||
---
|
||||
|
||||
### Shell — interfaz `Provider` y registro de proveedores
|
||||
|
||||
```go
|
||||
// shell/llm/provider.go
|
||||
|
||||
type Provider interface {
|
||||
Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Registry mapea nombres de modelo a constructores de Provider
|
||||
type Registry struct {
|
||||
factories map[string]Factory
|
||||
}
|
||||
|
||||
type Factory func(cfg map[string]any, logger *slog.Logger) (Provider, error)
|
||||
|
||||
func (r *Registry) Register(name string, f Factory)
|
||||
func (r *Registry) Build(name string, cfg map[string]any, logger *slog.Logger) (Provider, error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Shell — proveedor HTTP (el que ya existe o existiría)
|
||||
|
||||
```go
|
||||
// shell/llm/anthropic/provider.go
|
||||
|
||||
type AnthropicProvider struct {
|
||||
client *http.Client
|
||||
apiKey string
|
||||
model string // "claude-sonnet-4-20250514", etc.
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewAnthropicProvider(cfg map[string]any, logger *slog.Logger) (llm.Provider, error)
|
||||
|
||||
func (p *AnthropicProvider) Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) {
|
||||
// Construir JSON → POST /v1/messages → parsear respuesta
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Shell — proveedor Claude Code (el nuevo)
|
||||
|
||||
```go
|
||||
// shell/llm/claudecode/provider.go
|
||||
|
||||
type ClaudeCodeProvider struct {
|
||||
binary string
|
||||
maxTurns int
|
||||
timeout time.Duration
|
||||
allowedTools []string
|
||||
workingDir string
|
||||
systemPrompt string // contenido leído del archivo en construcción
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewClaudeCodeProvider(cfg map[string]any, logger *slog.Logger) (llm.Provider, error)
|
||||
|
||||
func (p *ClaudeCodeProvider) Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) {
|
||||
// 1. Construir el prompt final: system prompt del provider + messages del request
|
||||
// 2. Armar los args de claude -p
|
||||
// 3. Ejecutar subproceso
|
||||
// 4. Parsear JSON de salida
|
||||
// 5. Mapear a CompletionResult
|
||||
}
|
||||
```
|
||||
|
||||
#### Construcción del comando (interno del provider)
|
||||
|
||||
```go
|
||||
func (p *ClaudeCodeProvider) buildArgs() []string {
|
||||
args := []string{"-p", "--output-format", "json"}
|
||||
|
||||
if p.maxTurns > 0 {
|
||||
args = append(args, "--max-turns", strconv.Itoa(p.maxTurns))
|
||||
}
|
||||
if len(p.allowedTools) > 0 {
|
||||
args = append(args, "--allowedTools", strings.Join(p.allowedTools, ","))
|
||||
}
|
||||
if p.systemPrompt != "" {
|
||||
args = append(args, "--system-prompt", p.systemPrompt)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func (p *ClaudeCodeProvider) Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, p.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Aplanar messages a un solo prompt para stdin
|
||||
prompt := flattenMessages(req.Messages)
|
||||
|
||||
cmd := exec.CommandContext(ctx, p.binary, p.buildArgs()...)
|
||||
cmd.Dir = p.workingDir
|
||||
cmd.Stdin = strings.NewReader(prompt)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
return p.parseOutput(stdout.Bytes(), stderr.Bytes(), err)
|
||||
}
|
||||
```
|
||||
|
||||
#### Parseo de la salida JSON
|
||||
|
||||
```go
|
||||
// claude -p --output-format json devuelve JSON lines con cada mensaje
|
||||
// El último bloque con role:"assistant" contiene la respuesta final
|
||||
|
||||
type claudeOutputMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
// ... campos adicionales del formato JSON de claude
|
||||
}
|
||||
|
||||
func (p *ClaudeCodeProvider) parseOutput(stdout, stderr []byte, execErr error) (core.CompletionResult, error) {
|
||||
// Parsear JSON lines, extraer último mensaje assistant
|
||||
// Mapear exit code a FinishReason
|
||||
// Extraer token usage si está disponible
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Registro en el arranque
|
||||
|
||||
```go
|
||||
// shell/llm/registry_defaults.go
|
||||
|
||||
func NewDefaultRegistry() *Registry {
|
||||
r := &Registry{factories: make(map[string]Factory)}
|
||||
|
||||
r.Register("sonnet", anthropic.NewAnthropicProvider)
|
||||
r.Register("haiku", anthropic.NewAnthropicProvider)
|
||||
r.Register("opus", anthropic.NewAnthropicProvider)
|
||||
r.Register("claude-code", claudecode.NewClaudeCodeProvider) // ← nuevo
|
||||
|
||||
return r
|
||||
}
|
||||
```
|
||||
|
||||
### Instanciación en el runtime del agente
|
||||
|
||||
```go
|
||||
// agents/runtime.go
|
||||
|
||||
func (a *Agent) init(registry *llm.Registry) error {
|
||||
provider, err := registry.Build(a.cfg.Model, a.cfg.ModelConfig, a.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building LLM provider %q: %w", a.cfg.Model, err)
|
||||
}
|
||||
a.llm = provider
|
||||
return nil
|
||||
}
|
||||
|
||||
// Después, cuando el agente necesita razonar:
|
||||
func (a *Agent) handleMessage(ctx context.Context, msg Message) (string, error) {
|
||||
req := core.CompletionRequest{
|
||||
SystemPrompt: a.systemPrompt,
|
||||
Messages: a.buildMessages(msg),
|
||||
}
|
||||
result, err := a.llm.Complete(ctx, req) // ← no sabe si es HTTP o subproceso
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Content, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diferencia clave vs. modelo HTTP
|
||||
|
||||
| Aspecto | Proveedor HTTP (`sonnet`) | Proveedor Claude Code (`claude-code`) |
|
||||
|---|---|---|
|
||||
| Transporte | HTTP a `api.anthropic.com` | Subproceso local `claude -p` |
|
||||
| Auth | API key | Session de Claude Code (login previo) |
|
||||
| Capacidades extra | Solo texto in/out | Agentic: bash, files, git dentro de `claude -p` |
|
||||
| Latencia | Baja por request | Mayor (startup del proceso + múltiples turnos internos) |
|
||||
| Costo | Por tokens via API | Por tokens via Claude Code (misma cuenta) |
|
||||
| Estado | Stateless | Puede mantener sesión (`--session-id`) |
|
||||
| Working dir | N/A | El worktree del agente |
|
||||
|
||||
---
|
||||
|
||||
## Flatten de mensajes para `claude -p`
|
||||
|
||||
`claude -p` recibe el prompt por stdin como texto plano. Hay que aplanar el historial:
|
||||
|
||||
```go
|
||||
func flattenMessages(msgs []core.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case "user":
|
||||
fmt.Fprintf(&b, "User: %s\n\n", m.Content)
|
||||
case "assistant":
|
||||
fmt.Fprintf(&b, "Assistant: %s\n\n", m.Content)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
```
|
||||
|
||||
Alternativa para conversaciones largas: usar `--session-id` y enviar solo el último mensaje.
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
|
||||
- `core/llm/types.go` — revisar que `CompletionRequest`/`CompletionResult` estén completos
|
||||
- `shell/llm/provider.go` — interfaz `Provider`, `Registry`, `Factory`
|
||||
- `shell/llm/anthropic/provider.go` — proveedor HTTP (refactorizar si ya existe)
|
||||
- **`shell/llm/claudecode/provider.go`** — proveedor Claude Code (nuevo)
|
||||
- `shell/llm/claudecode/parser.go` — parseo de JSON output de `claude -p`
|
||||
- `shell/llm/registry_defaults.go` — registro de proveedores disponibles
|
||||
- `agents/runtime.go` — usar `Registry.Build()` para instanciar el provider del agente
|
||||
- `internal/config/schema.go` — validar `model_config` según el `model` elegido
|
||||
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
- **Fase 1**: Provider básico — stdin/stdout, sin sesiones, timeout simple
|
||||
- **Fase 2**: Soporte de `--session-id` para conversaciones con estado (el agente mantiene el session ID entre interacciones)
|
||||
- **Fase 3**: Streaming — `claude -p --output-format stream-json` para respuestas parciales en tiempo real a la sala Matrix
|
||||
- **Fase 4**: Pool de procesos — reutilizar sesiones de Claude Code para reducir latencia de startup
|
||||
- El agente no necesita implementar tools propios para bash/git/files si usa `claude-code` como modelo — Claude Code ya los tiene
|
||||
- Respetar `ctx` de shutdown: matar el subproceso con `cmd.Process.Kill()` si el contexto se cancela
|
||||
- El `working_dir` debería ser el worktree del agente para que Claude Code tenga contexto del repo
|
||||
@@ -0,0 +1,284 @@
|
||||
# Tarea: Implementar Sistema de Logging Estructurado para Agentes
|
||||
|
||||
## Contexto del Proyecto
|
||||
|
||||
Estamos construyendo un sistema multi-agente en Go con las siguientes características arquitectónicas:
|
||||
|
||||
- **Separación pure core / impure shell**: el core retorna decisiones como datos, el shell las ejecuta e interactúa con el mundo exterior.
|
||||
- **Monorepo en Go** con módulos separados.
|
||||
- **Comunicación inter-agente via Matrix** (mautrix-go) como bus de mensajes.
|
||||
- **Múltiples agentes** con identidades independientes (cada uno con su propio contexto Git, etc.).
|
||||
- **Integración con múltiples LLM providers** (Anthropic, OpenAI-compatible, Ollama) via abstracción unificada.
|
||||
|
||||
El logging vive en el **impure shell** — nunca en el core.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear un paquete `pkg/logger` (o `internal/logger`) que provea logging estructurado en formato JSONL, optimizado para ser consumido tanto por humanos como por agentes LLM. Los logs deben ser fácilmente parseables, consultables por fecha/agente, y auto-gestionados (rotación, limpieza).
|
||||
|
||||
## Requisitos Funcionales
|
||||
|
||||
### 1. Formato de Salida: JSONL
|
||||
|
||||
Cada línea de log es un objeto JSON independiente con los siguientes campos obligatorios:
|
||||
|
||||
```json
|
||||
{
|
||||
"time": "2026-03-06T10:00:00.000Z",
|
||||
"level": "INFO",
|
||||
"msg": "agent action completed",
|
||||
"agent_id": "researcher-01",
|
||||
"trace_id": "abc123",
|
||||
"component": "shell"
|
||||
}
|
||||
```
|
||||
|
||||
Campos opcionales según contexto:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "web_search",
|
||||
"duration_ms": 342,
|
||||
"tokens_used": 1500,
|
||||
"result": "success",
|
||||
"error_type": "timeout",
|
||||
"reason": "user requested summary of recent papers",
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
El campo `reason` es especialmente importante: cuando otro agente lee el log, necesita saber *por qué* se tomó una decisión, no solo *qué* se hizo.
|
||||
|
||||
### 2. Segmentación de Archivos
|
||||
|
||||
Estructura de directorios por agente y por día:
|
||||
|
||||
```
|
||||
/var/log/agents/
|
||||
├── orchestrator/
|
||||
│ ├── 2026-03-04.jsonl
|
||||
│ ├── 2026-03-05.jsonl
|
||||
│ └── 2026-03-06.jsonl
|
||||
├── researcher-01/
|
||||
│ ├── 2026-03-05.jsonl
|
||||
│ └── 2026-03-06.jsonl
|
||||
└── coder-01/
|
||||
└── 2026-03-06.jsonl
|
||||
```
|
||||
|
||||
Reglas:
|
||||
- Un archivo JSONL por agente por día.
|
||||
- Si un archivo excede un tamaño máximo configurable (default: 50MB), se rota añadiendo un sufijo incremental: `2026-03-06.jsonl` → `2026-03-06.1.jsonl`.
|
||||
- Nombres de archivo siempre en formato `YYYY-MM-DD.jsonl`.
|
||||
|
||||
### 3. Rotación y Limpieza
|
||||
|
||||
- **Retención configurable** (default: 7 días).
|
||||
- **Goroutine de limpieza** que corre periódicamente (default: cada 24h) y elimina archivos que excedan la retención.
|
||||
- **Compresión opcional** de archivos rotados (gzip).
|
||||
- La limpieza debe ser segura para ejecución concurrente.
|
||||
|
||||
### 4. API del Logger
|
||||
|
||||
```go
|
||||
// Config para crear un logger de agente
|
||||
type LoggerConfig struct {
|
||||
BaseDir string // directorio raíz de logs (default: "/var/log/agents")
|
||||
AgentID string // identificador único del agente
|
||||
MaxSizeMB int64 // tamaño máximo por archivo (default: 50)
|
||||
MaxAgeDays int // días de retención (default: 7)
|
||||
Compress bool // comprimir archivos rotados (default: true)
|
||||
CleanupInterval time.Duration // intervalo de limpieza (default: 24h)
|
||||
Level slog.Level // nivel mínimo de log (default: slog.LevelInfo)
|
||||
}
|
||||
|
||||
// Factory function
|
||||
func NewAgentLogger(cfg LoggerConfig) (*slog.Logger, func(), error)
|
||||
// Retorna:
|
||||
// - *slog.Logger: logger configurado con slog
|
||||
// - func(): función de cleanup para llamar en shutdown (cierra archivos, detiene goroutine de limpieza)
|
||||
// - error: si no se puede crear el directorio o el archivo inicial
|
||||
|
||||
// Uso esperado:
|
||||
logger, cleanup, err := logger.NewAgentLogger(logger.LoggerConfig{
|
||||
AgentID: "researcher-01",
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
logger.InfoContext(ctx, "executing decision",
|
||||
"action", decision.Action,
|
||||
"reason", decision.Reason,
|
||||
"trace_id", traceIDFromCtx(ctx),
|
||||
"tokens_used", 1500,
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Writer Personalizado
|
||||
|
||||
Implementar un `io.Writer` que maneje la rotación diaria con fallback por tamaño:
|
||||
|
||||
```go
|
||||
type DailyRotatingWriter struct {
|
||||
baseDir string
|
||||
agentID string
|
||||
maxSizeMB int64
|
||||
compress bool
|
||||
|
||||
mu sync.Mutex
|
||||
current *os.File
|
||||
written int64
|
||||
currentDay string
|
||||
suffix int // para rotación por tamaño dentro del mismo día
|
||||
}
|
||||
|
||||
// Debe implementar io.Writer
|
||||
func (w *DailyRotatingWriter) Write(p []byte) (n int, err error)
|
||||
|
||||
// Cierre limpio
|
||||
func (w *DailyRotatingWriter) Close() error
|
||||
```
|
||||
|
||||
Lógica de `Write`:
|
||||
1. Adquirir lock.
|
||||
2. Verificar si el día cambió (`time.Now().Format("2006-01-02")` vs `w.currentDay`).
|
||||
3. Si cambió el día: cerrar archivo actual, comprimir si `compress=true`, abrir nuevo archivo del día, resetear `written` y `suffix`.
|
||||
4. Si `written > maxSizeMB * 1024 * 1024`: incrementar `suffix`, abrir nuevo archivo (`2026-03-06.1.jsonl`), resetear `written`.
|
||||
5. Escribir `p` al archivo actual.
|
||||
6. Incrementar `written`.
|
||||
|
||||
### 6. Helpers para Consulta por LLMs
|
||||
|
||||
Proveer funciones utilitarias para que los agentes puedan consultar logs:
|
||||
|
||||
```go
|
||||
// Leer logs de un agente en un rango de fechas
|
||||
func ReadLogs(baseDir, agentID string, from, to time.Time) ([]json.RawMessage, error)
|
||||
|
||||
// Leer logs de un agente para un día específico
|
||||
func ReadDayLogs(baseDir, agentID string, date time.Time) ([]json.RawMessage, error)
|
||||
|
||||
// Buscar logs que contengan un campo con un valor específico
|
||||
func SearchLogs(baseDir, agentID string, field, value string, from, to time.Time) ([]json.RawMessage, error)
|
||||
|
||||
// Listar agentes disponibles (subdirectorios)
|
||||
func ListAgents(baseDir string) ([]string, error)
|
||||
|
||||
// Listar fechas disponibles para un agente
|
||||
func ListDates(baseDir, agentID string) ([]time.Time, error)
|
||||
```
|
||||
|
||||
Estas funciones permiten que un agente LLM solicite logs con interfaces simples. El agente orquestador puede usar `SearchLogs` para buscar errores, o `ReadDayLogs` para obtener contexto de lo que hizo otro agente ayer.
|
||||
|
||||
## Requisitos No Funcionales
|
||||
|
||||
- **Stdlib primero**: usar `log/slog` como base. No dependencias externas excepto lo estrictamente necesario (si lumberjack simplifica, se puede usar, pero la implementación custom del `DailyRotatingWriter` es preferida).
|
||||
- **Thread-safe**: múltiples goroutines escribirán al mismo logger.
|
||||
- **Mínimo overhead**: el logging no debe impactar significativamente el rendimiento del agente. Escribir en buffer si es necesario.
|
||||
- **Consistencia de campos**: usar los mismos nombres de campo siempre. Definir constantes para campos estándar:
|
||||
|
||||
```go
|
||||
const (
|
||||
FieldAgentID = "agent_id"
|
||||
FieldTraceID = "trace_id"
|
||||
FieldAction = "action"
|
||||
FieldReason = "reason"
|
||||
FieldDurationMS = "duration_ms"
|
||||
FieldTokensUsed = "tokens_used"
|
||||
FieldResult = "result"
|
||||
FieldErrorType = "error_type"
|
||||
FieldComponent = "component"
|
||||
)
|
||||
```
|
||||
|
||||
- **Testeable**: incluir tests unitarios para:
|
||||
- Rotación por día.
|
||||
- Rotación por tamaño dentro del mismo día.
|
||||
- Limpieza de archivos viejos.
|
||||
- Formato de salida JSONL correcto.
|
||||
- Concurrencia (múltiples writers simultáneos).
|
||||
- Funciones de consulta (`ReadLogs`, `SearchLogs`).
|
||||
|
||||
## Estructura de Archivos Esperada
|
||||
|
||||
```
|
||||
pkg/logger/
|
||||
├── logger.go // NewAgentLogger, LoggerConfig, constantes de campos
|
||||
├── writer.go // DailyRotatingWriter implementation
|
||||
├── cleanup.go // Goroutine de limpieza y compresión
|
||||
├── query.go // ReadLogs, SearchLogs, ListAgents, ListDates
|
||||
├── logger_test.go // Tests del logger y formato
|
||||
├── writer_test.go // Tests de rotación
|
||||
├── cleanup_test.go // Tests de limpieza
|
||||
└── query_test.go // Tests de consulta
|
||||
```
|
||||
|
||||
## Restricciones
|
||||
|
||||
- Go 1.21+ (para `log/slog` nativo).
|
||||
- Sin CGO.
|
||||
- Sin dependencias externas (stdlib pura). Si consideras que alguna dependencia aporta valor significativo, justifícala explícitamente.
|
||||
- El logger debe poder funcionar tanto escribiendo a archivos como a stdout (para desarrollo/debugging), configurable via `LoggerConfig`.
|
||||
- Todos los timestamps en UTC.
|
||||
|
||||
## Ejemplo de Integración
|
||||
|
||||
Así se vería el uso del logger dentro del shell de un agente:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"myproject/pkg/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log, cleanup, err := logger.NewAgentLogger(logger.LoggerConfig{
|
||||
AgentID: "researcher-01",
|
||||
BaseDir: "/var/log/agents",
|
||||
Level: slog.LevelInfo,
|
||||
Compress: true,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = logger.WithTraceID(ctx, "trace-abc-123")
|
||||
|
||||
// El core retorna una decisión pura
|
||||
decision := core.Decide(input)
|
||||
|
||||
// El shell loguea y ejecuta
|
||||
log.InfoContext(ctx, "executing decision",
|
||||
logger.FieldAction, decision.Action,
|
||||
logger.FieldReason, decision.Reason,
|
||||
logger.FieldComponent, "shell",
|
||||
)
|
||||
|
||||
result, err := shell.Execute(ctx, decision)
|
||||
if err != nil {
|
||||
log.ErrorContext(ctx, "decision execution failed",
|
||||
logger.FieldAction, decision.Action,
|
||||
logger.FieldErrorType, categorizeError(err),
|
||||
"error", err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
log.InfoContext(ctx, "decision executed successfully",
|
||||
logger.FieldAction, decision.Action,
|
||||
logger.FieldResult, "success",
|
||||
logger.FieldDurationMS, result.DurationMS,
|
||||
logger.FieldTokensUsed, result.TokensUsed,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas Adicionales
|
||||
|
||||
- El `trace_id` permite correlacionar un flujo completo a través de múltiples agentes. Si el orchestrator inicia una tarea y delega al researcher, ambos usan el mismo `trace_id`.
|
||||
- Considerar un helper `WithTraceID(ctx, id)` / `TraceIDFromCtx(ctx)` usando `context.Value`.
|
||||
- El campo `reason` captura la intención detrás de la acción. Un LLM que lee "reason: user requested summary of recent AI papers" entiende el contexto sin necesidad de reconstruirlo desde mensajes anteriores.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Tarea 08 — Knowledge por agente
|
||||
|
||||
## Objetivo
|
||||
|
||||
Cada agente tiene una carpeta `knowledge/` donde almacena documentos de conocimiento (markdown).
|
||||
El agente puede buscar, leer, escribir y mejorar su propio conocimiento usando tools siempre disponibles.
|
||||
El conocimiento es archivos reales — inspeccionables por humanos, editables, y se pueden sembrar con contenido inicial.
|
||||
|
||||
## Diseño
|
||||
|
||||
### Almacenamiento híbrido: archivos + índice FTS5
|
||||
|
||||
```
|
||||
agents/<id>/knowledge/ ← archivos .md reales (human-readable)
|
||||
├── go-patterns.md
|
||||
├── user-preferences.md
|
||||
└── matrix-tips.md
|
||||
|
||||
agents/<id>/data/knowledge.db ← índice SQLite FTS5 (búsqueda rápida)
|
||||
```
|
||||
|
||||
- Los documentos viven como archivos `.md` en `knowledge/`.
|
||||
- Un índice FTS5 en SQLite permite búsqueda full-text instantánea.
|
||||
- Al iniciar, se sincroniza: archivos → índice (detecta nuevos, modificados, eliminados).
|
||||
- Al escribir via tool, se actualiza archivo + índice atómicamente.
|
||||
|
||||
### Por qué archivos y no solo SQLite
|
||||
|
||||
1. **Sembrables**: se puede crear `knowledge/` con documentos iniciales antes de arrancar
|
||||
2. **Inspeccionables**: un humano puede leer/editar el conocimiento del agente
|
||||
3. **Git-friendly**: opcionalmente trackeable en el repo
|
||||
4. **Naturales**: el agente "escribe documentos", no inserta rows
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura (pure core / impure shell)
|
||||
|
||||
### 1. Pure core: `pkg/knowledge/`
|
||||
|
||||
```go
|
||||
// pkg/knowledge/types.go
|
||||
package knowledge
|
||||
|
||||
import "time"
|
||||
|
||||
// Document represents a knowledge document.
|
||||
type Document struct {
|
||||
Slug string // filename sin extensión, e.g. "go-patterns"
|
||||
Title string // primera línea H1 del markdown, o slug humanizado
|
||||
Content string // contenido completo del archivo
|
||||
UpdatedAt time.Time // mtime del archivo
|
||||
}
|
||||
|
||||
// SearchResult is a document matched by a search query.
|
||||
type SearchResult struct {
|
||||
Slug string
|
||||
Title string
|
||||
Snippet string // fragmento relevante con match highlights
|
||||
Rank float64 // relevancia FTS5
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// pkg/knowledge/store.go
|
||||
package knowledge
|
||||
|
||||
import "context"
|
||||
|
||||
// Store is the pure interface for knowledge operations.
|
||||
// Implemented by shell/knowledge.
|
||||
type Store interface {
|
||||
// Search performs full-text search across all documents.
|
||||
Search(ctx context.Context, query string, limit int) ([]SearchResult, error)
|
||||
|
||||
// Get retrieves a document by slug.
|
||||
Get(ctx context.Context, slug string) (*Document, error)
|
||||
|
||||
// Put creates or updates a document (file + index).
|
||||
Put(ctx context.Context, doc Document) error
|
||||
|
||||
// Delete removes a document (file + index).
|
||||
Delete(ctx context.Context, slug string) error
|
||||
|
||||
// List returns all document slugs with titles.
|
||||
List(ctx context.Context) ([]Document, error)
|
||||
|
||||
// Sync re-indexes all files from disk. Called on startup.
|
||||
Sync(ctx context.Context) error
|
||||
|
||||
// Close releases resources.
|
||||
Close() error
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Impure shell: `shell/knowledge/`
|
||||
|
||||
```go
|
||||
// shell/knowledge/store.go
|
||||
package knowledge
|
||||
|
||||
// FileStore implements knowledge.Store using files + SQLite FTS5.
|
||||
type FileStore struct {
|
||||
dir string // path a agents/<id>/knowledge/
|
||||
dbPath string // path a agents/<id>/data/knowledge.db
|
||||
db *sql.DB
|
||||
logger *slog.Logger
|
||||
}
|
||||
```
|
||||
|
||||
**Schema SQLite:**
|
||||
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS documents USING fts5(
|
||||
slug,
|
||||
title,
|
||||
content,
|
||||
updated_at UNINDEXED
|
||||
);
|
||||
```
|
||||
|
||||
**Operaciones:**
|
||||
|
||||
| Método | Archivos | SQLite FTS5 |
|
||||
|--------|----------|-------------|
|
||||
| `Sync()` | Lee todos los `.md` del dir | Reconstruye índice completo |
|
||||
| `Search()` | — | `SELECT slug, title, snippet(...) FROM documents WHERE documents MATCH ?` |
|
||||
| `Get()` | Lee `{slug}.md` | — |
|
||||
| `Put()` | Escribe `{slug}.md` | Upsert en FTS5 |
|
||||
| `Delete()` | Borra `{slug}.md` | Delete en FTS5 |
|
||||
| `List()` | — | `SELECT slug, title FROM documents` |
|
||||
|
||||
**Sync al startup:**
|
||||
1. Listar `*.md` en el directorio
|
||||
2. Para cada archivo: leer contenido, extraer título (primer `# ...`), calcular mtime
|
||||
3. `DELETE FROM documents` + re-insertar todo (rebuild completo, simple y correcto)
|
||||
4. Log: `knowledge_sync count=N`
|
||||
|
||||
**Slug rules:**
|
||||
- Solo `[a-z0-9-]`, máximo 64 chars
|
||||
- Derivado del nombre de archivo sin `.md`
|
||||
- El tool valida antes de escribir
|
||||
|
||||
### 3. Tools: `tools/knowledge.go`
|
||||
|
||||
Cuatro tools que el agente siempre tiene disponibles cuando knowledge está habilitado:
|
||||
|
||||
#### `knowledge_search`
|
||||
```
|
||||
Nombre: knowledge_search
|
||||
Descripción: Search your knowledge base for relevant documents. Returns matching snippets ranked by relevance.
|
||||
Parámetros:
|
||||
- query (string, required): Search terms or phrase
|
||||
- limit (integer, optional): Max results, default 5
|
||||
Retorna: Lista de resultados con slug, título y snippet
|
||||
```
|
||||
|
||||
#### `knowledge_read`
|
||||
```
|
||||
Nombre: knowledge_read
|
||||
Descripción: Read the full content of a knowledge document by its slug.
|
||||
Parámetros:
|
||||
- slug (string, required): Document slug (e.g. "go-patterns")
|
||||
Retorna: Contenido completo del documento
|
||||
```
|
||||
|
||||
#### `knowledge_write`
|
||||
```
|
||||
Nombre: knowledge_write
|
||||
Descripción: Create or update a knowledge document. Use this to save new knowledge or improve existing documents.
|
||||
Parámetros:
|
||||
- slug (string, required): Document slug (lowercase, hyphens, e.g. "matrix-tips")
|
||||
- content (string, required): Full markdown content of the document
|
||||
Retorna: Confirmación con slug y tamaño
|
||||
```
|
||||
|
||||
#### `knowledge_list`
|
||||
```
|
||||
Nombre: knowledge_list
|
||||
Descripción: List all documents in your knowledge base with their titles.
|
||||
Parámetros: ninguno
|
||||
Retorna: Lista de slugs con títulos y fecha de última actualización
|
||||
```
|
||||
|
||||
> **Nota:** No incluyo `knowledge_delete` por ahora. Los agentes deberían mejorar y ampliar, no borrar. Si se necesita, se añade después.
|
||||
|
||||
### 4. Config: `internal/config/schema.go`
|
||||
|
||||
```go
|
||||
type KnowledgeCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Dir string `yaml:"dir"` // default: "./knowledge" (relativo al dir del agente)
|
||||
}
|
||||
```
|
||||
|
||||
Añadir a `ToolsCfg`:
|
||||
```go
|
||||
type ToolsCfg struct {
|
||||
// ... existentes ...
|
||||
Knowledge KnowledgeCfg `yaml:"knowledge"`
|
||||
}
|
||||
```
|
||||
|
||||
Config de ejemplo en `config.yaml`:
|
||||
```yaml
|
||||
tools:
|
||||
knowledge:
|
||||
enabled: true
|
||||
dir: "./knowledge" # opcional, default relativo al agente
|
||||
```
|
||||
|
||||
### 5. Registro en runtime: `agents/runtime.go`
|
||||
|
||||
En `buildToolRegistry()`, después de los memory tools:
|
||||
|
||||
```go
|
||||
if cfg.Tools.Knowledge.Enabled {
|
||||
knowledgeDir := resolveKnowledgeDir(cfg) // resolve relative to agent dir
|
||||
knowledgeDBPath := filepath.Join(cfg.Storage.DataDir, "knowledge.db")
|
||||
kStore, err := shellknowledge.New(knowledgeDir, knowledgeDBPath, logger)
|
||||
if err != nil {
|
||||
logger.Error("knowledge_store_init_failed", "err", err)
|
||||
} else {
|
||||
// Sync on startup
|
||||
if err := kStore.Sync(ctx); err != nil {
|
||||
logger.Error("knowledge_sync_failed", "err", err)
|
||||
}
|
||||
reg.Register(tools.NewKnowledgeSearch(kStore))
|
||||
reg.Register(tools.NewKnowledgeRead(kStore))
|
||||
reg.Register(tools.NewKnowledgeWrite(kStore))
|
||||
reg.Register(tools.NewKnowledgeList(kStore))
|
||||
logger.Debug("registered knowledge tools")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plan de implementación (orden)
|
||||
|
||||
### Paso 1 — Pure types (`pkg/knowledge/`)
|
||||
- [ ] `pkg/knowledge/types.go` — Document, SearchResult
|
||||
- [ ] `pkg/knowledge/store.go` — Store interface
|
||||
|
||||
### Paso 2 — Config
|
||||
- [ ] Añadir `KnowledgeCfg` a `internal/config/schema.go` dentro de `ToolsCfg`
|
||||
|
||||
### Paso 3 — Shell store (`shell/knowledge/`)
|
||||
- [ ] `shell/knowledge/store.go` — FileStore con FTS5
|
||||
- Constructor `New(dir, dbPath, logger)`
|
||||
- Sync(), Search(), Get(), Put(), Delete(), List(), Close()
|
||||
- Validación de slugs
|
||||
- Extracción de título del markdown (primer `# `)
|
||||
|
||||
### Paso 4 — Tools (`tools/knowledge.go`)
|
||||
- [ ] `tools/knowledge.go` — NewKnowledgeSearch, NewKnowledgeRead, NewKnowledgeWrite, NewKnowledgeList
|
||||
- [ ] Interface `KnowledgeStore` en tools (subset de knowledge.Store, como se hizo con MemoryStore)
|
||||
|
||||
### Paso 5 — Registro en runtime
|
||||
- [ ] Modificar `buildToolRegistry()` en `agents/runtime.go`
|
||||
- [ ] Resolver directorio de knowledge relativo al agente
|
||||
|
||||
### Paso 6 — Activar en agentes existentes
|
||||
- [ ] Crear `agents/assistant-bot/knowledge/` con un documento semilla
|
||||
- [ ] Crear `agents/asistente-2/knowledge/` con un documento semilla
|
||||
- [ ] Actualizar `config.yaml` de ambos agentes: `tools.knowledge.enabled: true`
|
||||
- [ ] Actualizar system prompts para que el agente sepa que tiene knowledge tools
|
||||
|
||||
### Paso 7 — Tests
|
||||
- [ ] Test de `shell/knowledge/` — sync, search, put, get, list
|
||||
- [ ] Test de `tools/knowledge.go` — validación de slugs, parámetros
|
||||
- [ ] Build completo: `go build -tags goolm ./...`
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso por el agente
|
||||
|
||||
Un usuario le dice al bot: "¿Cómo configuro un webhook en Gitea?"
|
||||
|
||||
1. El agente llama `knowledge_search(query="gitea webhook")`
|
||||
2. Encuentra `gitea-admin.md` con snippet relevante
|
||||
3. Llama `knowledge_read(slug="gitea-admin")` para leer el documento completo
|
||||
4. Responde al usuario con la info
|
||||
5. Si descubre info nueva en la conversación, llama `knowledge_write(slug="gitea-webhooks", content="# Gitea Webhooks\n\n...")` para ampliar su base
|
||||
|
||||
## Diferencia con memory tools
|
||||
|
||||
| Aspecto | Memory (facts) | Knowledge (documents) |
|
||||
|---------|----------------|----------------------|
|
||||
| Granularidad | Key-value individual | Documentos completos |
|
||||
| Búsqueda | Por subject exacto | Full-text search (FTS5) |
|
||||
| Formato | Tripla (subject, key, value) | Markdown libre |
|
||||
| Propósito | Datos puntuales sobre users/temas | Base de conocimiento estructurada |
|
||||
| Persistencia | SQLite rows | Archivos .md + índice FTS5 |
|
||||
| Editable por humanos | No (solo via SQL) | Sí (archivos normales) |
|
||||
|
||||
---
|
||||
|
||||
## Notas de implementación
|
||||
|
||||
- **FTS5 y modernc/sqlite**: modernc.org/sqlite soporta FTS5 nativamente, no necesita CGO.
|
||||
- **Slugs**: validar con regexp `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$` (min 2 chars).
|
||||
- **Título**: extraer primera línea que empiece con `# `. Si no hay, usar slug humanizado.
|
||||
- **Tamaño máximo por documento**: 64 KB (consistente con read_file tool).
|
||||
- **Directorio knowledge/ en .gitignore**: decisión del usuario. Se puede trackear o no.
|
||||
- **No embeddings**: FTS5 keyword search es suficiente para v1. Embeddings es extensión futura.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Task 09 — Sistema de comandos directos (!command)
|
||||
|
||||
## Objetivo
|
||||
|
||||
Implementar un sistema de comandos que permita a los usuarios ejecutar acciones directamente via `!comando` sin depender del LLM. Soportar agentes "simple_bot" que no tienen LLM y solo responden a comandos.
|
||||
|
||||
## Contexto actual
|
||||
|
||||
- `message.Parse` ya detecta `CommandPrefix` (!) y extrae `Command` + `Args` en `MessageContext`
|
||||
- `decision.MatchCommand()` ya existe para matchear comandos en reglas
|
||||
- `tools.Registry` ya tiene `Execute(ctx, name, argsJSON)` para ejecutar tools
|
||||
- Cada agente define sus reglas en `agent.go` con `Rules() []decision.Rule`
|
||||
- El flujo actual: solo `!help` existe como comando hardcodeado en cada agente
|
||||
|
||||
## Problema
|
||||
|
||||
- Los comandos estan hardcodeados en cada `agent.go` como reglas individuales
|
||||
- No hay forma de ejecutar tools directamente sin pasar por el LLM
|
||||
- No hay comandos built-in compartidos entre agentes
|
||||
- No se puede crear un bot sin LLM (simple_bot)
|
||||
- El `!help` es estatico y no refleja las tools reales del agente
|
||||
|
||||
## Diseno
|
||||
|
||||
### Arquitectura (pure core / impure shell)
|
||||
|
||||
```
|
||||
pkg/command/ -> PURE: tipos Command, parser de args, specs built-in
|
||||
agents/runtime.go -> composicion: conecta commands con tools y shell
|
||||
```
|
||||
|
||||
### Tipos de comandos
|
||||
|
||||
1. **Built-in commands** (disponibles en todos los agentes):
|
||||
|
||||
| Comando | Descripcion |
|
||||
|------------|----------------------------------------------------|
|
||||
| `!help` | Lista comandos disponibles (built-in + custom) |
|
||||
| `!tools` | Lista tools registradas con descripcion |
|
||||
| `!ping` | Alive check, responde "pong" con timestamp |
|
||||
| `!status` | Info del agente: uptime, rooms activos, window sizes |
|
||||
| `!info` | Nombre, version, descripcion del agente |
|
||||
| `!clear` | Limpia ventana de conversacion del room actual |
|
||||
| `!version` | Version del agente |
|
||||
|
||||
2. **Tool commands** — ejecutar tools directas:
|
||||
```
|
||||
!tool <nombre> -> sin args
|
||||
!tool <nombre> key=value -> arg simple
|
||||
!tool <nombre> key="valor con espacios" -> arg con espacios
|
||||
!tool <nombre> key=value key2=value2 -> multiples args
|
||||
```
|
||||
Ejemplos:
|
||||
- `!tool ssh_command host=server1 command="uptime"`
|
||||
- `!tool current_time`
|
||||
- `!tool knowledge_search query="como configurar"`
|
||||
|
||||
3. **Custom commands** — definidos por cada agente en su `agent.go` via Rules con MatchCommand (como ahora, pero mejor integrados)
|
||||
|
||||
### Flujo de ejecucion
|
||||
|
||||
```
|
||||
Matrix event
|
||||
-> message.Parse (ya extrae Command + Args)
|
||||
-> handleEvent:
|
||||
1. Si hay Command (empieza con !prefix):
|
||||
a. Custom command del agente (rules con MatchCommand)? -> ejecutar regla
|
||||
b. Built-in command? -> ejecutar handler, responder
|
||||
c. "tool" command? -> parsear args, ejecutar via tools.Registry, responder
|
||||
d. No encontrado? -> responder "comando desconocido, usa !help"
|
||||
2. Si NO es comando: flujo actual (rules -> LLM fallback si hay LLM)
|
||||
3. Si NO es comando y NO hay LLM: ignorar (solo responde a comandos)
|
||||
```
|
||||
|
||||
**Nota**: las reglas custom del agente tienen prioridad sobre built-ins. Si un agente define una regla `MatchCommand("help")` propia, esa gana sobre el built-in.
|
||||
|
||||
### Nuevo paquete `pkg/command/` (puro)
|
||||
|
||||
```go
|
||||
// pkg/command/types.go
|
||||
|
||||
// Spec es la spec pura de un comando. Solo datos.
|
||||
type Spec struct {
|
||||
Name string
|
||||
Aliases []string // e.g. ["h"] para help
|
||||
Description string // descripcion corta para !help
|
||||
Usage string // e.g. "!tool <name> [key=value ...]"
|
||||
Hidden bool // no mostrar en !help
|
||||
}
|
||||
|
||||
// ParsedArgs resultado de parsear "key=value key2=value2"
|
||||
type ParsedArgs struct {
|
||||
Positional []string // args sin key=
|
||||
Named map[string]string // args con key=value
|
||||
Raw []string // args originales
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// pkg/command/parse.go
|
||||
|
||||
// ParseArgs convierte []string{"host=server1", "command=uptime"} en ParsedArgs. Puro.
|
||||
func ParseArgs(args []string) ParsedArgs { ... }
|
||||
|
||||
// ArgsToJSON convierte ParsedArgs.Named a JSON string para tools.Registry.Execute. Puro.
|
||||
func ArgsToJSON(named map[string]string) string { ... }
|
||||
```
|
||||
|
||||
```go
|
||||
// pkg/command/builtins.go
|
||||
|
||||
// Builtins retorna las specs de todos los comandos built-in. Puro.
|
||||
func Builtins() []Spec { ... }
|
||||
```
|
||||
|
||||
### Cambios en `agents/runtime.go`
|
||||
|
||||
```go
|
||||
// CommandHandler ejecuta un comando built-in y devuelve la respuesta texto.
|
||||
type CommandHandler func(ctx context.Context, msgCtx decision.MessageContext) string
|
||||
|
||||
// Nuevos campos en Agent:
|
||||
type Agent struct {
|
||||
// ... existente ...
|
||||
commands map[string]CommandHandler // built-in command handlers
|
||||
startTime time.Time // para !status
|
||||
}
|
||||
```
|
||||
|
||||
En `handleEvent`, el flujo cambia a:
|
||||
```go
|
||||
// 1. Evaluar reglas custom primero (pueden overridear built-ins)
|
||||
if msgCtx.Command != "" {
|
||||
actions := decision.Evaluate(msgCtx, a.rules)
|
||||
if len(actions) > 0 {
|
||||
// ejecutar como ahora (expand LLM actions, runner.Execute)
|
||||
return
|
||||
}
|
||||
// 2. Buscar en built-ins
|
||||
if handler, ok := a.commands[msgCtx.Command]; ok {
|
||||
reply := handler(ctx, msgCtx)
|
||||
a.matrix.SendText(ctx, roomID, reply)
|
||||
return
|
||||
}
|
||||
// 3. Comando desconocido
|
||||
a.matrix.SendText(ctx, roomID, "Comando desconocido. Usa !help")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Sin comando: LLM fallback (si hay LLM) o ignorar
|
||||
if a.llm == nil {
|
||||
return // simple_bot: solo responde a comandos
|
||||
}
|
||||
// ... flujo LLM actual (DM/mention -> LLM) ...
|
||||
```
|
||||
|
||||
### Simple bots (sin LLM)
|
||||
|
||||
Un simple_bot se configura sin seccion `llm` o con `llm.primary.provider: ""`:
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
id: monitor-bot
|
||||
name: Monitor Bot
|
||||
enabled: true
|
||||
description: "Bot de monitoreo, solo comandos"
|
||||
|
||||
tools:
|
||||
ssh:
|
||||
enabled: true
|
||||
allowed_targets: ["webserver"]
|
||||
```
|
||||
|
||||
En `New()`, si no hay LLM configurado, `a.llm` queda nil. El bot solo responde a comandos.
|
||||
|
||||
## Tareas de implementacion
|
||||
|
||||
### Fase 1 — Core puro (`pkg/command/`)
|
||||
- [x] Crear `pkg/command/types.go` — tipos Spec, ParsedArgs
|
||||
- [x] Crear `pkg/command/parse.go` — ParseArgs, ArgsToJSON
|
||||
- [x] Crear `pkg/command/parse_test.go` — tests del parser
|
||||
- [x] Crear `pkg/command/builtins.go` — specs de los 7 comandos built-in + BuiltinNames()
|
||||
|
||||
### Fase 2 — Handlers en runtime (`agents/`)
|
||||
- [x] Agregar campos `commands`, `cmdAliases`, `startTime` al Agent struct
|
||||
- [x] Implementar handlers: help, tools, ping, info, version, clear, status
|
||||
- [x] Implementar handler `tool` — parsea args key=value, ejecuta via Registry, formatea respuesta
|
||||
- [x] Registrar todos los handlers en `New()` via `registerBuiltinCommands()`
|
||||
- [x] Modificar `handleEvent` — nuevo flujo: rules custom -> built-in -> comando desconocido -> LLM fallback
|
||||
- [x] Extraer `executeActions()` helper para reutilizar en ambos flujos
|
||||
|
||||
### Fase 3 — Simple bot support
|
||||
- [x] Hacer LLM opcional en `New()` (no fallar si no hay provider)
|
||||
- [x] Si `a.llm == nil` y no hay comando, ignorar mensaje
|
||||
- [ ] Verificar que un agente sin LLM arranca y responde a !help, !tool, !ping
|
||||
|
||||
### Fase 4 — Integracion con agentes existentes
|
||||
- [x] Eliminar regla `!help` hardcodeada de assistant-bot/agent.go
|
||||
- [x] Eliminar regla `!help` hardcodeada de asistente-2/agent.go
|
||||
- [x] Verificar que reglas custom (llm-all, etc.) siguen funcionando (build OK)
|
||||
- [ ] Test manual: !help, !tools, !tool current_time, !ping, !status, !clear, !info, !version
|
||||
|
||||
### Fase 5 (futura) — Simple bot de ejemplo
|
||||
- [ ] Crear agente simple_bot de ejemplo sin LLM
|
||||
- [ ] Documentar patron simple_bot
|
||||
@@ -0,0 +1,79 @@
|
||||
# Tarea 11 — Renderizar mensajes como Markdown en Matrix
|
||||
|
||||
## Problema
|
||||
|
||||
Todos los mensajes de los agentes (respuestas LLM, comandos, errores) se envían como texto plano
|
||||
via `SendText()`. Matrix soporta mensajes con `format: org.matrix.custom.html` + `formatted_body`
|
||||
para renderizar Markdown (negrita, código, listas, etc.) en clientes como Element.
|
||||
|
||||
Existe un `SendMarkdown()` en `shell/matrix/client.go` pero tiene dos problemas:
|
||||
1. Solo se usa en un único lugar (`runtime.go:617` — notificación de tool use).
|
||||
2. No convierte Markdown a HTML: pone el markdown crudo en `FormattedBody`, que Matrix espera como HTML.
|
||||
|
||||
## Alcance
|
||||
|
||||
### 1. Añadir conversión Markdown → HTML (`shell/matrix/client.go`)
|
||||
|
||||
- Añadir dependencia `github.com/yuin/goldmark` (parser Markdown → HTML estándar, muy usado en Go).
|
||||
- Corregir `SendMarkdown()` para que convierta el body de Markdown a HTML antes de ponerlo en `FormattedBody`.
|
||||
- `Body` queda como texto plano (fallback para clientes que no soportan HTML) — se puede dejar el markdown crudo ahí, que es lo estándar en Matrix.
|
||||
|
||||
```go
|
||||
func (c *Client) SendMarkdown(ctx context.Context, roomID, markdown string) error {
|
||||
html := mdToHTML(markdown) // nueva función interna
|
||||
content := event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: markdown,
|
||||
Format: event.FormatHTML,
|
||||
FormattedBody: html,
|
||||
}
|
||||
_, err := c.raw.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Cambiar la interfaz `MatrixSender` para exponer `SendMarkdown`
|
||||
|
||||
- `shell/effects/runner.go`: añadir `SendMarkdown(ctx, roomID, text) error` a la interfaz `MatrixSender`.
|
||||
- `tools/matrix.go`: añadir `SendMarkdown` a la interfaz `MatrixToolSender` (o como se llame).
|
||||
|
||||
### 3. Cambiar todos los call sites de `SendText` → `SendMarkdown`
|
||||
|
||||
Puntos a cambiar:
|
||||
|
||||
| Archivo | Línea(s) | Contexto |
|
||||
|---------|----------|----------|
|
||||
| `agents/runtime.go:394` | Respuesta de tarea orquestada | `SendText → SendMarkdown` |
|
||||
| `agents/runtime.go:456` | Reply LLM (loop) | `SendText → SendMarkdown` |
|
||||
| `agents/runtime.go:462` | Reply LLM (fallback) | `SendText → SendMarkdown` |
|
||||
| `shell/effects/runner.go:68` | Runner.executeOne (ActionKindReply) | `SendText → SendMarkdown` |
|
||||
| `agents/runtime.go:456` | Comando ejecutado (!xxx) | `SendText → SendMarkdown` |
|
||||
| `agents/runtime.go:462` | Comando desconocido | `SendText → SendMarkdown` |
|
||||
|
||||
### 4. Mantener `SendText` para uso interno/futuro
|
||||
|
||||
No eliminar `SendText`, solo dejar de usarlo como canal principal de respuesta.
|
||||
Podría ser útil para mensajes que realmente no necesitan formato (logs internos, debugging).
|
||||
|
||||
### 5. Actualizar interfaz en tests/mocks
|
||||
|
||||
Cualquier mock de `MatrixSender` que exista en tests necesitará el método `SendMarkdown`.
|
||||
|
||||
## Tareas ordenadas
|
||||
|
||||
- [ ] `go get github.com/yuin/goldmark`
|
||||
- [ ] Crear función `mdToHTML(md string) string` en `shell/matrix/` (usa goldmark)
|
||||
- [ ] Corregir `SendMarkdown()` para usar `mdToHTML`
|
||||
- [ ] Añadir `SendMarkdown` a la interfaz `MatrixSender` en `shell/effects/runner.go`
|
||||
- [ ] Cambiar `runner.executeOne` (ActionKindReply) de `SendText` → `SendMarkdown`
|
||||
- [ ] Cambiar `runtime.go` — respuesta de comandos (!xxx) a `SendMarkdown`
|
||||
- [ ] Cambiar `runtime.go` — respuesta de tarea orquestada a `SendMarkdown`
|
||||
- [ ] Actualizar interfaz en `tools/matrix.go` si aplica
|
||||
- [ ] Actualizar mocks en tests
|
||||
- [ ] Test manual: enviar mensaje al bot y verificar que Element renderiza markdown
|
||||
|
||||
## Notas
|
||||
|
||||
- goldmark es safe por defecto (escapa HTML peligroso) — no hay riesgo XSS.
|
||||
- El `Body` del evento Matrix queda como markdown crudo — esto es correcto según la spec de Matrix (es el fallback plaintext).
|
||||
- Los mensajes de error simples ("Comando desconocido: !foo") también pasan por `SendMarkdown` — no pasa nada, goldmark los deja como `<p>texto</p>` sin más.
|
||||
Reference in New Issue
Block a user