feat: add assistant bot with LLM integration and configuration

- Implemented the assistant bot with basic command handling and LLM routing.
- Created configuration file for the assistant bot with personality, behavior, and LLM settings.
- Added system prompt for the assistant bot to define its capabilities and limitations.
- Developed registration script for creating Matrix bot users via Synapse admin API.
- Introduced common development scripts for agent management (start, stop, list, logs).
- Scaffolded new agent creation script to streamline the addition of new agents.
- Implemented agent removal script to disable agents without deleting data.
This commit is contained in:
2026-03-03 23:57:13 +00:00
parent c126187c5a
commit bd8e1432e5
19 changed files with 2142 additions and 93 deletions
+149
View File
@@ -0,0 +1,149 @@
# CLAUDE.md — Contexto del proyecto agents_and_robots
## Qué es este proyecto
Monorepo en Go para gestionar bots Matrix. Cada bot es un agente autónomo con personalidad, reglas de decisión y acceso a herramientas (LLM, SSH, HTTP, MCP). Los bots se comunican entre sí y con humanos a través de Matrix (mautrix-go).
**Homeserver activo:** `https://matrix-af2f3d.organic-machine.com`
**Server name:** `matrix-af2f3d.organic-machine.com`
## Filosofía de diseño — LA REGLA MÁS IMPORTANTE
El proyecto usa el patrón **pure core / impure shell** estrictamente:
```
pkg/ → PURE: solo tipos, funciones puras, cero side effects
shell/ → IMPURE: todo I/O, red, filesystem, procesos
agents/ → composición: reglas puras + ensamblado con shell en runtime.go
```
**Nunca** añadir side effects (I/O, red, llamadas a APIs) dentro de `pkg/`.
**Siempre** que el core necesite "hacer algo", produce un `[]decision.Action` (datos puros) que el shell interpreta.
El flujo es siempre:
```
Matrix event → Parse (pure) → Evaluate rules (pure) → []Action (pure data)
→ Runner.Execute (impure) → efectos reales
```
## Módulo Go
`github.com/enmanuel/agents`
## Estructura de directorios
```
pkg/decision/ → motor de reglas puro (Evaluate, Rule, MatchFunc, Action)
pkg/llm/ → tipos de LLM puros (CompleteFunc, CompletionRequest, Route)
pkg/tools/ → specs declarativas de herramientas (SSHCommandSpec, etc.)
pkg/message/ → parse y format de mensajes Matrix (puros)
pkg/personality/ → tipos de personalidad (Personality, Tone, etc.)
shell/llm/ → clientes LLM reales (anthropic.go, openai.go, factory.go)
shell/matrix/ → cliente Matrix mautrix-go (client.go, listener.go)
shell/ssh/ → ejecutor SSH real (executor.go)
shell/effects/ → Runner que interpreta []Action → side effects
shell/bus/ → comunicación inter-agente via Go channels
shell/protocols/ → adaptadores MCP (mcp.go)
agents/runtime.go → Agent{}: ensambla core + shell, maneja eventos
agents/assistant/ → reglas puras + config del assistant-bot
agents/devops/ → reglas puras + config del devops-bot
internal/config/schema.go → tipos completos del YAML de configuración
internal/config/loader.go → Load() con expand env vars, LoadMeta() sin validación
cmd/launcher/main.go → inicia agentes, tiene rulesRegistry
cmd/agentctl/main.go → CLI de gestión (list, start, stop, remove)
cmd/register/main.go → registra bots en Synapse via admin API
dev-scripts/ → scripts bash para operaciones del día a día
```
## Agentes existentes
| ID | Estado | LLM | Descripción |
|----------------|-----------|---------|------------------------------------------|
| assistant-bot | activo | GPT-4o | Asistente general, responde DMs |
| devops-bot | pendiente | Claude | SSH, deployments, healthchecks |
## Dependencias clave
```
maunium.net/go/mautrix v0.21.1 → Matrix client
github.com/sashabaranov/go-openai → OpenAI + Ollama-compatible
github.com/mark3labs/mcp-go → MCP protocol server/client
golang.org/x/crypto/ssh → SSH execution
github.com/spf13/cobra → CLI
gopkg.in/yaml.v3 → Config parsing
```
## Configuración de agentes
Cada agente vive en `agents/<id>/`:
- `config.yaml` — configuración completa (ver schema en `internal/config/schema.go`)
- `agent.go` — reglas puras que implementan `Rules() []decision.Rule`
- `prompts/system.md` — system prompt del LLM
- `data/` — datos de runtime (SQLite, crypto, logs) — en .gitignore
El `config.yaml` soporta variables de entorno con `${VAR}` y `$VAR`. El loader usa `os.ExpandEnv`.
Secciones principales del config: `agent`, `personality`, `llm`, `tools`, `matrix`, `agents` (peers), `ssh`, `security`, `schedules`, `observability`, `resilience`, `storage`.
## Cómo añadir un nuevo bot
1. Generar scaffold: `./dev-scripts/new-agent.sh <id> "Display Name"`
2. Registrarlo en Matrix: `./dev-scripts/register.sh <id> "Display Name"`
3. Añadir el token al `.env`
4. Añadir una línea en `cmd/launcher/main.go``rulesRegistry`
5. Editar `agents/<id>/agent.go` con las reglas reales
6. Arrancar: `./dev-scripts/start.sh <id>`
## Dev-scripts disponibles
```bash
./dev-scripts/list.sh # ver todos los bots y estado
./dev-scripts/start.sh [agent-id] # iniciar uno o todos
./dev-scripts/stop.sh [agent-id] # detener uno o todos
./dev-scripts/remove.sh <agent-id> # deshabilitar (sin borrar datos)
./dev-scripts/register.sh <id> [name] # registrar bot en Matrix
./dev-scripts/logs.sh [agent-id] # tail -f de logs
./dev-scripts/new-agent.sh <id> [name] # scaffold completo
```
PID files: `run/<id>.pid` | Log files: `run/<id>.log`
## Gestión de procesos
Los bots corren como procesos independientes lanzados por `agentctl` o `dev-scripts/start.sh`.
Cada proceso escribe su PID en `run/<id>.pid` y su log en `run/<id>.log`.
`is_running()` usa `kill -0 <pid>` para verificar sin matar el proceso.
## Variables de entorno críticas
```bash
MATRIX_HOMESERVER # URL del servidor Matrix
MATRIX_SERVER_NAME # nombre del servidor (parte después de :)
MATRIX_ADMIN_TOKEN # token admin para registrar bots (cmd/register)
MATRIX_TOKEN_<BOT> # access token de cada bot
OPENAI_API_KEY # OpenAI
ANTHROPIC_API_KEY # Anthropic/Claude
SSH_PRIVATE_KEY_PATH # clave SSH para el devops-bot
```
Nunca commitear `.env`. Plantilla en `.env.example`.
## Preferencias del usuario
- **Idioma**: español en configs/comentarios de dominio, inglés en código Go
- **Estilo**: FP estricto (pure core / impure shell), sin abstracción prematura
- **Desarrollo**: trunk-based, Gitea como remote
- **Go version**: 1.23.5 en `/usr/local/go/bin`
- **No usar** frameworks de agentes externos — arquitectura propia
## Próximas extensiones naturales
- Scheduling: cron runner en `shell/` para `ScheduleCfg`
- Conversation history: mantener `[]Message` por room en memoria/SQLite
- RBAC real: conectar `SecurityCfg.Roles` al listener
- E2EE: habilitar `encryption.enabled: true` con crypto store de mautrix
- MCP: exponer tools del devops-bot como MCP server en el puerto configurado
- A2A: agent card HTTP endpoint para comunicación con agentes externos
+24 -19
View File
@@ -3,32 +3,37 @@
# NEVER commit .env to git.
# ============================================================
# Matrix
MATRIX_HOMESERVER=https://matrix.example.com
MATRIX_SERVER_NAME=example.com
MATRIX_TOKEN_DEVOPS=syt_...
MATRIX_TOKEN_MONITOR=syt_...
# ── Matrix ───────────────────────────────────────────────────
MATRIX_HOMESERVER=https://matrix-af2f3d.organic-machine.com
MATRIX_SERVER_NAME=matrix-af2f3d.organic-machine.com
# Admin token — solo necesario para correr cmd/register
# Obtenerlo desde Element > Settings > Help & About > Access Token
MATRIX_ADMIN_TOKEN=syt_...
# Tokens de cada bot — generados por cmd/register
MATRIX_TOKEN_ASSISTANT=syt_...
MATRIX_TOKEN_DEVOPS=syt_...
# Matrix room IDs (!roomid:server)
MATRIX_ROOM_DEVOPS=!abc123:example.com
MATRIX_ROOM_ALERTS=!def456:example.com
MATRIX_ROOM_LOGS=!ghi789:example.com
MATRIX_ROOM_ADMIN=!xyz000:example.com
MATRIX_ROOM_AUDIT=!aud001:example.com
MATRIX_ROOM_AGENTS_INTERNAL=!int002:example.com
# LLM providers
ANTHROPIC_API_KEY=sk-ant-...
# ── LLM providers ────────────────────────────────────────────
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-... # opcional, para cuando añadas el devops-bot con Claude
# SSH
SSH_PRIVATE_KEY_PATH=/home/deploy/.ssh/id_ed25519
SSH_MONITOR_KEY_PATH=/home/monitor/.ssh/id_ed25519
# ── SSH (para devops-bot, cuando lo añadas) ──────────────────
SSH_PRIVATE_KEY_PATH=/home/ubuntu/.ssh/id_ed25519
SSH_MONITOR_KEY_PATH=/home/ubuntu/.ssh/id_ed25519
# Infrastructure hosts
# ── Infrastructure hosts (para devops-bot) ───────────────────
PROD_HOST_1=10.0.1.10
PROD_HOST_2=10.0.1.11
STAGING_HOST=10.0.2.10
MONITORING_HOST=10.0.3.10
BASTION_HOST=bastion.example.com
# ── Matrix rooms (opcionales — el assistant-bot opera en DMs) ─
MATRIX_ROOM_DEVOPS=
MATRIX_ROOM_ALERTS=
MATRIX_ROOM_LOGS=
MATRIX_ROOM_ADMIN=
MATRIX_ROOM_AUDIT=
MATRIX_ROOM_AGENTS_INTERNAL=
+53
View File
@@ -0,0 +1,53 @@
BIN := bin
LDFLAGS := -ldflags="-s -w"
.PHONY: build build-launcher build-agentctl build-register \
list start stop remove register \
clean tidy
# ── Build ──────────────────────────────────────────────────────────────────
build: build-launcher build-agentctl build-register
build-launcher:
@mkdir -p $(BIN)
go build $(LDFLAGS) -o $(BIN)/launcher ./cmd/launcher
build-agentctl:
@mkdir -p $(BIN)
go build $(LDFLAGS) -o $(BIN)/agentctl ./cmd/agentctl
build-register:
@mkdir -p $(BIN)
go build $(LDFLAGS) -o $(BIN)/register ./cmd/register
# ── Agent management (shortcuts via agentctl) ──────────────────────────────
list:
@go run ./cmd/agentctl list
start:
@go run ./cmd/agentctl start $(AGENT)
stop:
@go run ./cmd/agentctl stop $(AGENT)
remove:
@go run ./cmd/agentctl remove $(AGENT)
# Usage: make register USERNAME=assistant-bot DISPLAYNAME="Assistant" ENV_VAR=MATRIX_TOKEN_ASSISTANT
register:
MATRIX_ADMIN_TOKEN=$$MATRIX_ADMIN_TOKEN \
go run ./cmd/register \
--homeserver $$MATRIX_HOMESERVER \
--username $(USERNAME) \
--displayname "$(DISPLAYNAME)" \
--env-var $(ENV_VAR)
# ── Dev ────────────────────────────────────────────────────────────────────
tidy:
go mod tidy
clean:
rm -rf $(BIN) run/
+273
View File
@@ -0,0 +1,273 @@
# agents_and_robots
Plataforma en Go para gestionar bots Matrix autónomos. Cada bot combina un **core puro** (personalidad, reglas de decisión, transformaciones) con un **shell impuro** (conexión Matrix, SSH, LLM, side effects), conectados a un servidor Matrix self-hosted.
---
## Principio de diseño
El proyecto usa el patrón **pure core / impure shell**:
```
Mensaje Matrix
Parse() ← puro: produce MessageContext
Evaluate() ← puro: produce []Action (solo datos, sin efectos)
Runner.Execute() ← impuro: interpreta las acciones → envía mensajes, ejecuta SSH, llama LLM
```
Nada dentro de `pkg/` tiene efectos secundarios. Todo el I/O vive en `shell/`.
---
## Estructura
```
agents_and_robots/
├── pkg/ ← PURE CORE — sin side effects
│ ├── decision/ engine de reglas: Evaluate(), Rule, Action, MatchFunc
│ ├── llm/ tipos LLM: CompleteFunc, CompletionRequest
│ ├── tools/ specs declarativas: SSHCommandSpec, HTTPCallSpec...
│ ├── message/ parse y format de mensajes
│ └── personality/ tipos de personalidad del bot
├── shell/ ← IMPURE SHELL — todo el I/O
│ ├── llm/ clientes reales: Anthropic, OpenAI/Ollama
│ ├── matrix/ cliente mautrix-go: envío, sync, listener
│ ├── ssh/ ejecución SSH real (golang.org/x/crypto/ssh)
│ ├── effects/ Runner: interpreta []Action → side effects
│ ├── bus/ mensajería inter-agente via Go channels
│ └── protocols/ MCP server/client (mark3labs/mcp-go)
├── agents/ ← definición de cada bot
│ ├── runtime.go Agent{}: ensambla core + shell
│ ├── assistant/ reglas + config del assistant-bot
│ └── devops/ reglas + config del devops-bot
├── internal/config/ esquema YAML completo + loader con env vars
├── cmd/
│ ├── launcher/ inicia uno o varios agentes
│ ├── agentctl/ CLI: list, start, stop, remove
│ └── register/ registra bots en Synapse via admin API
├── dev-scripts/ scripts bash para el día a día
├── config/ configuración global (matrix.yaml, servers.yaml)
└── .env.example plantilla de variables de entorno
```
---
## Requisitos
- Go 1.23+
- Servidor Matrix (Synapse) con acceso admin
- API key de OpenAI o Anthropic (según el bot)
---
## Setup inicial
```bash
# 1. Clonar y entrar al repo
git clone <repo-url>
cd agents_and_robots
# 2. Copiar y rellenar variables de entorno
cp .env.example .env
# Editar .env con: MATRIX_HOMESERVER, OPENAI_API_KEY, etc.
# 3. Descargar dependencias
go mod tidy
```
---
## Registrar y arrancar un bot
```bash
# Registrar el bot en el servidor Matrix (necesita MATRIX_ADMIN_TOKEN en .env)
./dev-scripts/register.sh assistant-bot "Assistant"
# → imprime el token, copiarlo a .env como MATRIX_TOKEN_ASSISTANT
# Ver todos los bots y su estado
./dev-scripts/list.sh
# Iniciar
./dev-scripts/start.sh assistant-bot
# Ver logs en vivo
./dev-scripts/logs.sh assistant-bot
# Detener
./dev-scripts/stop.sh assistant-bot
```
---
## CLI — agentctl
```bash
go run ./cmd/agentctl list # ver todos los bots
go run ./cmd/agentctl start assistant-bot # iniciar
go run ./cmd/agentctl stop # detener todos
go run ./cmd/agentctl remove assistant-bot # deshabilitar (sin borrar datos)
```
O compilando los binarios:
```bash
make build # genera bin/launcher, bin/agentctl, bin/register
./bin/agentctl list
```
---
## Crear un bot nuevo
```bash
# 1. Generar el scaffold completo
./dev-scripts/new-agent.sh monitor-bot "Monitor Agent"
```
Genera:
```
agents/monitor-bot/
├── config.yaml ← configuración completa, lista para editar
├── agent.go ← reglas puras con help + LLM fallback
└── prompts/
└── system.md ← system prompt del LLM
```
El script imprime los dos pasos manuales que quedan:
```bash
# 2. Añadir al registro en cmd/launcher/main.go:
import monitoragent "github.com/enmanuel/agents/agents/monitor-bot"
var rulesRegistry = map[string]func() []decision.Rule{
"monitor-bot": monitoragent.Rules, // ← añadir aquí
...
}
# 3. Registrarlo en Matrix y arrancar
./dev-scripts/register.sh monitor-bot "Monitor Agent"
# → añadir token a .env
./dev-scripts/start.sh monitor-bot
```
---
## Configuración de agentes
Cada `agents/<id>/config.yaml` soporta:
| Sección | Qué controla |
|---------|--------------|
| `agent` | identidad, versión, tags, enabled |
| `personality` | tono, verbosidad, idioma, emoji, templates de respuesta, comportamiento |
| `llm` | provider (anthropic/openai/ollama), modelo, fallback, rate limits, tool use |
| `tools` | SSH, HTTP, scripts, file_ops, MCP (habilitados por bot) |
| `matrix` | homeserver, user_id, rooms, filtros de mensajes, E2EE |
| `agents` | peers con los que colabora, delegación, protocolo |
| `ssh` | inventario de targets con hosts, users, jump host |
| `security` | RBAC por roles, audit log, gestión de secrets |
| `schedules` | tareas cron automáticas con acciones SSH/script |
| `observability` | logging, métricas Prometheus, health check, tracing |
| `resilience` | circuit breaker, retry, graceful shutdown, queue |
| `storage` | estado SQLite/Redis, caché, historial de conversación |
Las variables del sistema se referencian como `${NOMBRE_VAR}` y se expanden en tiempo de carga.
---
## Reglas de decisión
Las reglas de cada bot se definen como datos puros en `agents/<id>/agent.go`:
```go
func Rules() []decision.Rule {
return []decision.Rule{
{
Name: "deploy-staging",
Match: decision.And(
decision.MatchCommand("deploy"),
func(ctx decision.MessageContext) bool {
return len(ctx.Args) > 0 && ctx.Args[0] == "staging"
},
),
Actions: []decision.Action{{
Kind: decision.ActionKindSSH,
SSH: &tools.SSHCommandSpec{Target: "staging", Command: "..."},
}},
},
// catch-all: DMs y menciones van al LLM
{
Name: "llm-fallback",
Match: func(ctx decision.MessageContext) bool {
return ctx.IsDirectMsg || ctx.IsMention
},
Actions: []decision.Action{{Kind: decision.ActionKindLLM, LLM: &decision.LLMAction{}}},
},
}
}
```
Predicados disponibles: `MatchCommand`, `MatchPrefix`, `MatchMinPowerLevel`, `MatchAny`, `And`, `Or`.
Tipos de acción: `reply`, `ssh`, `http`, `script`, `file_ops`, `mcp`, `llm`, `delegate`.
---
## Bots incluidos
### assistant-bot
Asistente general con GPT-4o. Responde a DMs y menciones. Sin acceso a herramientas — solo LLM.
```
@assistant-bot:matrix-af2f3d.organic-machine.com
```
### devops-bot
Bot de infraestructura. Ejecuta comandos SSH en targets configurados. Requiere `ANTHROPIC_API_KEY`.
Comandos Matrix:
- `!help` — lista de comandos
- `!healthcheck` — health check de producción
- `!status` — estado de servicios
- `!deploy staging` / `!deploy production` (requiere power level ≥ 50)
- `!logs` — últimas líneas de journalctl
---
## Dependencias
| Librería | Versión | Uso |
|----------|---------|-----|
| `maunium.net/go/mautrix` | v0.21.1 | Cliente Matrix, sync, E2EE |
| `github.com/sashabaranov/go-openai` | v1.36.1 | OpenAI API y compatibles (Ollama) |
| `github.com/mark3labs/mcp-go` | v0.44.1 | MCP protocol server/client |
| `golang.org/x/crypto` | v0.31.0 | SSH |
| `github.com/spf13/cobra` | v1.8.1 | CLI |
| `gopkg.in/yaml.v3` | v3.0.1 | Config |
---
## Makefile
```bash
make build # compila todos los binarios en bin/
make list # agentctl list
make start # start todos (AGENT=id para uno)
make stop # stop todos (AGENT=id para uno)
make tidy # go mod tidy
make clean # elimina bin/ y run/
```
+41
View File
@@ -0,0 +1,41 @@
// Package assistant defines the pure rules for the assistant bot.
// Since this bot is primarily LLM-driven, most rules just route to the LLM.
package assistant
import (
"github.com/enmanuel/agents/pkg/decision"
)
// Rules returns the decision rules for the assistant bot.
func Rules() []decision.Rule {
return []decision.Rule{
// !help — explicit help command
{
Name: "help",
Match: decision.MatchCommand("help"),
Actions: []decision.Action{{
Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{
Content: "Soy tu asistente. Escríbeme directamente lo que necesitas:\n" +
"- Preguntas, explicaciones, resúmenes\n" +
"- Ayuda con código\n" +
"- Redacción de textos\n\n" +
"Para tareas de infraestructura, habla con @devops-bot.",
},
}},
},
// Any DM or mention → LLM
// This is the catch-all: if the message is a DM or a mention, send to LLM.
{
Name: "llm-all",
Match: func(ctx decision.MessageContext) bool {
return ctx.IsDirectMsg || ctx.IsMention
},
Actions: []decision.Action{{
Kind: decision.ActionKindLLM,
LLM: &decision.LLMAction{},
}},
},
}
}
+269
View File
@@ -0,0 +1,269 @@
# ============================================
# IDENTIDAD
# ============================================
agent:
id: assistant-bot
name: "Assistant"
version: "1.0.0"
enabled: true
description: "Asistente general con acceso a LLM. Responde preguntas, resume, redacta y ayuda con tareas cotidianas."
tags: [assistant, llm, general]
# ============================================
# PERSONALIDAD Y COMPORTAMIENTO
# ============================================
personality:
tone: friendly
verbosity: concise
language: es
languages_supported: [es, en]
emoji_style: minimal
prefix: "🤖"
error_style: helpful
templates:
greeting: "Hola, soy tu asistente. ¿En qué puedo ayudarte?"
unknown_command: "No entiendo ese comando. Escríbeme directamente lo que necesitas."
permission_denied: "No tengo permiso para hacer eso."
error: "Algo salió mal: {{.Error}}"
success: "{{.Summary}}"
busy: "Procesando tu solicitud anterior, dame un momento..."
behavior:
proactive: false
ask_confirmation: false # el assistant no pide confirmación, solo el devops
show_reasoning: false
thread_replies: true
typing_indicator: true
acknowledge_receipt: false # responde directo, sin "recibido"
# ============================================
# LLM — CONEXIÓN Y RAZONAMIENTO
# ============================================
llm:
primary:
provider: openai
model: gpt-4o
api_key_env: OPENAI_API_KEY
base_url: ""
max_tokens: 4096
temperature: 0.7
# Sin fallback por ahora — añadir Ollama cuando esté disponible
fallback:
provider: ""
model: ""
api_key_env: ""
base_url: ""
max_tokens: 0
temperature: 0
reasoning:
system_prompt_file: "prompts/assistant-system.md"
context_window: 16384
memory_messages: 30 # mantiene 30 mensajes de historia por room/DM
tool_use:
enabled: false # el assistant no usa tools por ahora
max_iterations: 3
parallel_calls: false
rate_limit:
requests_per_minute: 60
tokens_per_minute: 200000
concurrent_requests: 5
# ============================================
# TOOLS — deshabilitadas para este bot
# ============================================
tools:
ssh:
enabled: false
allowed_targets: []
forbidden_commands: []
timeout: 0s
max_concurrent: 0
require_confirmation: []
http:
enabled: false
allowed_domains: []
timeout: 0s
max_retries: 0
scripts:
enabled: false
scripts_dir: ""
allowed: []
timeout: 0s
sandbox: false
file_ops:
enabled: false
allowed_paths: []
read_only: true
mcp:
enabled: false
servers: []
expose:
port: 0
tools: []
# ============================================
# MATRIX — CONEXIÓN Y ROOMS
# ============================================
matrix:
homeserver: "https://matrix-af2f3d.organic-machine.com"
user_id: "@assistant-bot:matrix-af2f3d.organic-machine.com"
access_token_env: MATRIX_TOKEN_ASSISTANT
device_id: "ASSISTANTBOT01"
encryption:
enabled: false
store_path: "./data/crypto/"
trust_mode: tofu
rooms:
listen: [] # vacío = escucha en todos los rooms donde está invitado
respond: [] # vacío = responde en todos
admin: []
filters:
command_prefix: "!"
mention_respond: true # responde cuando lo mencionan en un room
dm_respond: true # responde en DMs (modo principal por ahora)
ignore_bots: true
ignore_users: []
min_power_level: 0 # cualquiera puede hablar con el assistant
# ============================================
# COMUNICACIÓN INTER-AGENTES
# ============================================
agents:
peers:
- id: devops-bot
capabilities: [deploy, ssh, healthcheck]
room: "" # sin room interno por ahora
delegation:
enabled: false # el assistant no delega aún
can_delegate_to: []
can_receive_from: [devops-bot]
max_delegation_depth: 1
timeout: 30s
protocol:
format: json
channel: matrix
heartbeat_interval: 60s
# ============================================
# SSH — no aplica para este bot
# ============================================
ssh:
defaults:
user: ""
port: 22
key_file_env: ""
known_hosts: ""
keepalive_interval: 0s
timeout: 0s
targets: {}
# ============================================
# PERMISOS Y SEGURIDAD
# ============================================
security:
roles:
admin:
users: ["@admin:matrix-af2f3d.organic-machine.com"]
actions: ["*"]
user:
users: ["*"]
actions: ["ask", "help", "summarize"]
audit:
enabled: false
log_file: "./data/audit.log"
log_to_room: ""
include: []
secrets:
provider: env
# ============================================
# SCHEDULING — sin tareas automáticas
# ============================================
schedules: []
# ============================================
# OBSERVABILIDAD
# ============================================
observability:
logging:
level: info
format: json
output: stdout
file: "./data/assistant.log"
metrics:
enabled: false
port: 9091
path: /metrics
export: prometheus
health:
enabled: true
port: 8081
path: /healthz
tracing:
enabled: false
provider: ""
endpoint: ""
# ============================================
# RESILIENCIA
# ============================================
resilience:
circuit_breaker:
failure_threshold: 5
timeout: 30s
half_open_max: 2
retry:
max_attempts: 2
backoff: exponential
initial_delay: 1s
max_delay: 10s
shutdown:
timeout: 10s
drain_messages: true
save_state: false
state_file: ""
queue:
enabled: true
max_size: 100
priority_users: ["@admin:matrix-af2f3d.organic-machine.com"]
# ============================================
# ALMACENAMIENTO Y ESTADO
# ============================================
storage:
state:
backend: sqlite
path: "./data/assistant.db"
cache:
enabled: true
backend: memory
ttl: 5m
max_entries: 200
history:
backend: sqlite
path: "./data/history.db"
retention: 168h # 7 días
@@ -0,0 +1,24 @@
# Assistant Bot — System Prompt
Eres un asistente conversacional amigable y directo. Operas en Matrix, respondiendo mensajes directos (DMs) y menciones en rooms.
## Capacidades
- Responder preguntas generales
- Resumir texto o documentos pegados en el chat
- Redactar textos, emails, documentación
- Explicar conceptos técnicos y no técnicos
- Ayudar con código: revisar, corregir, explicar
## Limitaciones
- No tienes acceso a internet ni herramientas externas en esta configuración
- No ejecutas comandos ni accedes a servidores (eso es tarea del devops-bot)
- Si alguien pide algo que requiere infraestructura, redirige al devops-bot
## Estilo
- Respuestas concisas por defecto. Si necesitas extensión, pregunta primero.
- Usa markdown cuando ayude a la legibilidad (listas, código, headers)
- Idioma principal: español. Cambia al idioma del usuario si escribe en otro.
- Sin emojis excesivos. Uno o dos si aportan contexto.
## Contexto de la conversación
Mantienes el historial de la conversación en cada DM o room. Úsalo para dar continuidad a las respuestas.
+352 -36
View File
@@ -1,24 +1,55 @@
// Command agentctl is a CLI for inspecting and managing agents.
// Command agentctl manages Matrix agents: list, start, stop, remove.
//
// Usage:
//
// agentctl list # all agents with their status
// agentctl start # start all enabled agents
// agentctl start assistant-bot # start a specific agent
// agentctl stop # stop all running agents
// agentctl stop assistant-bot # stop a specific agent
// agentctl remove assistant-bot # disable agent (keeps data)
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/spf13/cobra"
"github.com/enmanuel/agents/internal/config"
)
const (
runDir = "run" // PID + log files
agentsGlob = "agents/*/config.yaml"
)
// ── entry point ───────────────────────────────────────────────────────────
func main() {
var binPath string
root := &cobra.Command{
Use: "agentctl",
Short: "Manage and inspect agents",
Short: "Manage Matrix agents",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return os.MkdirAll(runDir, 0o755)
},
}
root.PersistentFlags().StringVar(&binPath, "bin", "",
"Launcher binary path. Defaults to ./bin/launcher, falls back to 'go run ./cmd/launcher'")
root.AddCommand(
listCmd(),
validateCmd(),
startCmd(&binPath),
stopCmd(),
removeCmd(),
)
if err := root.Execute(); err != nil {
@@ -26,29 +57,31 @@ func main() {
}
}
// ── list ──────────────────────────────────────────────────────────────────
func listCmd() *cobra.Command {
return &cobra.Command{
Use: "list [config.yaml...]",
Short: "List agents from config files",
Use: "list",
Short: "List all agents and their current status",
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("provide at least one config file")
agents, err := scanAgents()
if err != nil {
return err
}
for _, path := range args {
cfg, err := config.Load(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)
continue
}
enabled := "enabled"
if !cfg.Agent.Enabled {
enabled = "disabled"
}
fmt.Printf("%-20s %-10s %-10s %s\n",
cfg.Agent.ID,
cfg.Agent.Version,
enabled,
cfg.Agent.Description,
if len(agents) == 0 {
fmt.Println("No agents found under agents/*/config.yaml")
return nil
}
fmt.Printf("%-20s %-12s %-8s %s\n", "ID", "STATUS", "VERSION", "DESCRIPTION")
fmt.Println(strings.Repeat("─", 72))
for _, a := range agents {
fmt.Printf("%-20s %-12s %-8s %s\n",
a.ID,
statusLabel(a),
a.Version,
truncate(a.Desc, 36),
)
}
return nil
@@ -56,25 +89,308 @@ func listCmd() *cobra.Command {
}
}
func validateCmd() *cobra.Command {
// ── start ─────────────────────────────────────────────────────────────────
func startCmd(binPath *string) *cobra.Command {
return &cobra.Command{
Use: "validate [config.yaml...]",
Short: "Validate agent config files",
Use: "start [agent-id...]",
Short: "Start one or all enabled agents",
RunE: func(cmd *cobra.Command, args []string) error {
allOK := true
for _, path := range args {
_, err := config.Load(path)
if err != nil {
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", path, err)
allOK = false
} else {
fmt.Printf("OK %s\n", path)
}
agents, err := scanAgents()
if err != nil {
return err
}
if !allOK {
os.Exit(1)
targets := filterTargets(agents, args)
if len(targets) == 0 {
return fmt.Errorf("no matching agents found")
}
bin := resolvedBin(*binPath)
started := 0
for _, a := range targets {
if !a.Enabled {
fmt.Printf("skip %-20s (disabled in config)\n", a.ID)
continue
}
if isRunning(a.ID) {
fmt.Printf("skip %-20s (already running, PID %d)\n", a.ID, readPID(a.ID))
continue
}
if err := startAgent(a, bin); err != nil {
fmt.Fprintf(os.Stderr, "fail %-20s %v\n", a.ID, err)
continue
}
fmt.Printf("start %-20s PID %d log → %s\n",
a.ID, readPID(a.ID), logPath(a.ID))
started++
}
if started == 0 {
fmt.Println("Nothing started.")
}
return nil
},
}
}
// ── stop ──────────────────────────────────────────────────────────────────
func stopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop [agent-id...]",
Short: "Stop one or all running agents",
RunE: func(cmd *cobra.Command, args []string) error {
agents, err := scanAgents()
if err != nil {
return err
}
targets := filterTargets(agents, args)
if len(targets) == 0 {
return fmt.Errorf("no matching agents found")
}
stopped := 0
for _, a := range targets {
pid := readPID(a.ID)
if pid == 0 || !isRunning(a.ID) {
fmt.Printf("skip %-20s (not running)\n", a.ID)
continue
}
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
fmt.Fprintf(os.Stderr, "fail %-20s kill: %v\n", a.ID, err)
continue
}
removePIDFile(a.ID)
fmt.Printf("stop %-20s sent SIGTERM to PID %d\n", a.ID, pid)
stopped++
}
if stopped == 0 {
fmt.Println("Nothing stopped.")
}
return nil
},
}
}
// ── remove ────────────────────────────────────────────────────────────────
func removeCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <agent-id>",
Short: "Disable an agent (sets enabled: false). Does not delete data.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := args[0]
agents, err := scanAgents()
if err != nil {
return err
}
var target *agentInfo
for i := range agents {
if agents[i].ID == id {
target = &agents[i]
break
}
}
if target == nil {
return fmt.Errorf("agent %q not found", id)
}
// Stop if running
if isRunning(id) {
pid := readPID(id)
_ = syscall.Kill(pid, syscall.SIGTERM)
removePIDFile(id)
fmt.Printf("stop %-20s sent SIGTERM to PID %d\n", id, pid)
}
// Disable in config (preserves comments)
if err := setEnabled(target.ConfigPath, false); err != nil {
return fmt.Errorf("update config: %w", err)
}
fmt.Printf("ok %-20s marked as disabled in %s\n", id, target.ConfigPath)
fmt.Printf(" Data preserved at agents/%s/data/\n", id)
return nil
},
}
}
// ── agent scanning ────────────────────────────────────────────────────────
type agentInfo struct {
ID string
Version string
Enabled bool
Desc string
ConfigPath string
}
func scanAgents() ([]agentInfo, error) {
matches, err := filepath.Glob(agentsGlob)
if err != nil {
return nil, err
}
var agents []agentInfo
for _, path := range matches {
// Use LoadMeta so list works even when env vars aren't set.
cfg, err := config.LoadMeta(path)
if err != nil {
fmt.Fprintf(os.Stderr, "warn skipping %s: %v\n", path, err)
continue
}
agents = append(agents, agentInfo{
ID: cfg.Agent.ID,
Version: cfg.Agent.Version,
Enabled: cfg.Agent.Enabled,
Desc: cfg.Agent.Description,
ConfigPath: path,
})
}
return agents, nil
}
func filterTargets(agents []agentInfo, ids []string) []agentInfo {
if len(ids) == 0 {
return agents // no filter → all
}
set := make(map[string]bool, len(ids))
for _, id := range ids {
set[id] = true
}
var out []agentInfo
for _, a := range agents {
if set[a.ID] {
out = append(out, a)
}
}
return out
}
// ── process management ────────────────────────────────────────────────────
func startAgent(a agentInfo, bin string) error {
logFile, err := os.OpenFile(logPath(a.ID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open log: %w", err)
}
var cmd *exec.Cmd
if strings.HasPrefix(bin, "go run") {
// dev mode: go run ./cmd/launcher -c <config>
cmd = exec.Command("go", "run", "./cmd/launcher", "-c", a.ConfigPath)
} else {
cmd = exec.Command(bin, "-c", a.ConfigPath)
}
cmd.Stdout = logFile
cmd.Stderr = logFile
// Detach from the parent process group so it keeps running after agentctl exits
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
logFile.Close()
return fmt.Errorf("exec: %w", err)
}
// Write PID file — the subprocess owns its lifecycle now
if err := os.WriteFile(pidPath(a.ID), []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil {
return fmt.Errorf("write PID: %w", err)
}
// Detach: don't wait for the process
go func() { _ = cmd.Wait() }()
return nil
}
func isRunning(id string) bool {
pid := readPID(id)
if pid == 0 {
return false
}
err := syscall.Kill(pid, 0) // signal 0 checks existence without killing
return err == nil
}
func readPID(id string) int {
raw, err := os.ReadFile(pidPath(id))
if err != nil {
return 0
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(raw)))
return pid
}
func removePIDFile(id string) {
_ = os.Remove(pidPath(id))
}
func pidPath(id string) string { return filepath.Join(runDir, id+".pid") }
func logPath(id string) string { return filepath.Join(runDir, id+".log") }
// ── config editing ────────────────────────────────────────────────────────
// setEnabled flips `enabled: true/false` in the agent section of the YAML.
// Uses text replacement to preserve all comments.
func setEnabled(configPath string, enabled bool) error {
raw, err := os.ReadFile(configPath)
if err != nil {
return err
}
current := "enabled: true"
replacement := "enabled: false"
if enabled {
current = "enabled: false"
replacement = "enabled: true"
}
updated := strings.Replace(string(raw), current, replacement, 1)
if updated == string(raw) {
return nil // already in the desired state
}
return os.WriteFile(configPath, []byte(updated), 0o644)
}
// ── display helpers ───────────────────────────────────────────────────────
func statusLabel(a agentInfo) string {
switch {
case !a.Enabled:
return "disabled"
case isRunning(a.ID):
return "● running"
default:
return "○ stopped"
}
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-1] + "…"
}
// resolvedBin returns the launcher binary path to use.
// Priority: --bin flag > ./bin/launcher (if exists) > go run fallback.
func resolvedBin(flagVal string) string {
if flagVal != "" {
return flagVal
}
if _, err := os.Stat("bin/launcher"); err == nil {
return "bin/launcher"
}
return "go run ./cmd/launcher"
}
+64 -38
View File
@@ -1,4 +1,9 @@
// Command launcher starts one or more agents from their config files.
//
// Usage:
//
// go run ./cmd/launcher # auto-discovers agents/*/config.yaml
// go run ./cmd/launcher -c agents/assistant/config.yaml
package main
import (
@@ -13,30 +18,49 @@ import (
"github.com/spf13/cobra"
"github.com/enmanuel/agents/agents"
assistantagent "github.com/enmanuel/agents/agents/assistant"
devopsagent "github.com/enmanuel/agents/agents/devops"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/decision"
)
// rulesRegistry maps agent IDs to their rule factories.
// Add a new entry here when you create a new agent package.
var rulesRegistry = map[string]func() []decision.Rule{
"assistant-bot": assistantagent.Rules,
"devops-bot": devopsagent.Rules,
}
func main() {
var configPaths []string
var logLevel string
var (
configPaths []string
logLevel string
)
root := &cobra.Command{
Use: "launcher",
Short: "Start Matrix agents from config files",
RunE: func(cmd *cobra.Command, args []string) error {
level := slog.LevelInfo
if logLevel == "debug" {
level = slog.LevelDebug
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if len(configPaths) == 0 {
matches, _ := filepath.Glob("agents/*/config.yaml")
configPaths = matches
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := newLogger(logLevel)
if len(configPaths) == 0 {
logger.Warn("no agent configs found — nothing to start")
return nil
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
var wg sync.WaitGroup
for _, path := range configPaths {
path := path // capture
path := path
cfg, err := config.Load(path)
if err != nil {
logger.Error("failed to load config", "path", path, "err", err)
@@ -47,10 +71,10 @@ func main() {
continue
}
// Load agent-specific rules (extend here with your own rule builders)
rules := loadRulesForAgent(cfg)
rules := rulesFor(cfg.Agent.ID, logger)
agentLogger := logger.With("agent", cfg.Agent.ID)
agent, err := agents.New(cfg, rules, logger.With("agent", cfg.Agent.ID))
a, err := agents.New(cfg, rules, agentLogger)
if err != nil {
logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", err)
continue
@@ -59,47 +83,49 @@ func main() {
wg.Add(1)
go func() {
defer wg.Done()
if err := agent.Run(ctx); err != nil {
logger.Error("agent stopped", "id", cfg.Agent.ID, "err", err)
agentLogger.Info("agent running")
if err := a.Run(ctx); err != nil {
agentLogger.Error("agent stopped with error", "err", err)
}
}()
}
wg.Wait()
logger.Info("all agents stopped")
return nil
},
}
root.Flags().StringSliceVarP(&configPaths, "config", "c", nil, "Agent config files (comma-separated or repeated flag)")
root.Flags().StringVar(&logLevel, "log-level", "info", "Log level: debug|info|warn|error")
// Default: discover all config.yaml files under agents/
root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
if len(configPaths) == 0 {
matches, _ := filepath.Glob("agents/*/config.yaml")
configPaths = matches
}
return nil
}
root.Flags().StringSliceVarP(&configPaths, "config", "c", nil,
"Agent config file(s). If omitted, discovers all agents/*/config.yaml")
root.Flags().StringVar(&logLevel, "log-level", "info",
"Log level: debug | info | warn | error")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// loadRulesForAgent returns the decision rules for a given agent config.
// Extend this function (or use a registry) to wire up agent-specific rules.
func loadRulesForAgent(cfg *config.AgentConfig) []decision.Rule {
return []decision.Rule{
{
Name: "help",
Match: decision.MatchCommand("help"),
Actions: []decision.Action{{
Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{
Content: "I'm " + cfg.Agent.Name + ". " + cfg.Agent.Description,
},
}},
},
func rulesFor(agentID string, logger *slog.Logger) []decision.Rule {
factory, ok := rulesRegistry[agentID]
if !ok {
logger.Warn("no rules registered for agent, using empty ruleset", "id", agentID)
return nil
}
return factory()
}
func newLogger(level string) *slog.Logger {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
}
+188
View File
@@ -0,0 +1,188 @@
// Command register creates a Matrix bot user via the Synapse admin API
// and outputs the access token to store in .env.
//
// Usage:
//
// MATRIX_ADMIN_TOKEN=syt_... go run ./cmd/register \
// --homeserver https://matrix-af2f3d.organic-machine.com \
// --username assistant-bot \
// --displayname "Assistant Bot" \
// --env-var MATRIX_TOKEN_ASSISTANT
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/spf13/cobra"
)
func main() {
var (
homeserver string
username string
displayname string
envVar string
password string
)
root := &cobra.Command{
Use: "register",
Short: "Register a Matrix bot user via Synapse admin API",
Long: `Creates a bot user on your Synapse homeserver and prints its access token.
Requires MATRIX_ADMIN_TOKEN env var with an admin user's access token.
Example:
MATRIX_ADMIN_TOKEN=syt_... go run ./cmd/register \
--homeserver https://matrix.example.com \
--username my-bot \
--displayname "My Bot" \
--env-var MATRIX_TOKEN_MY_BOT`,
RunE: func(cmd *cobra.Command, args []string) error {
adminToken := os.Getenv("MATRIX_ADMIN_TOKEN")
if adminToken == "" {
return fmt.Errorf("MATRIX_ADMIN_TOKEN env var is not set")
}
// Strip trailing slash
homeserver = strings.TrimRight(homeserver, "/")
// Extract server name from homeserver URL
serverName := homeserver
serverName = strings.TrimPrefix(serverName, "https://")
serverName = strings.TrimPrefix(serverName, "http://")
userID := fmt.Sprintf("@%s:%s", username, serverName)
fmt.Printf("→ Registering user %s on %s\n", userID, homeserver)
// Generate password if not provided
if password == "" {
password = generatePassword()
}
// Step 1: Create/update user via admin API
if err := createUser(homeserver, adminToken, userID, displayname, password); err != nil {
return fmt.Errorf("create user: %w", err)
}
fmt.Printf("✓ User %s created/updated\n", userID)
// Step 2: Login as the bot to get an access token
token, deviceID, err := loginAs(homeserver, username, password)
if err != nil {
return fmt.Errorf("login as bot: %w", err)
}
fmt.Printf("✓ Logged in, device ID: %s\n", deviceID)
// Step 3: Print results
fmt.Println("\n─── Add to your .env ───────────────────────────────")
fmt.Printf("%s=%s\n", envVar, token)
fmt.Printf("MATRIX_HOMESERVER=%s\n", homeserver)
fmt.Printf("MATRIX_SERVER_NAME=%s\n", serverName)
fmt.Println("────────────────────────────────────────────────────")
fmt.Printf("\nUser ID: %s\n", userID)
fmt.Printf("Device ID: %s\n", deviceID)
return nil
},
}
root.Flags().StringVar(&homeserver, "homeserver", "", "Matrix homeserver URL (required)")
root.Flags().StringVar(&username, "username", "", "Bot username, without @ or server (required)")
root.Flags().StringVar(&displayname, "displayname", "", "Bot display name shown in Matrix")
root.Flags().StringVar(&envVar, "env-var", "MATRIX_TOKEN_BOT", "Name of the env var to output")
root.Flags().StringVar(&password, "password", "", "Bot password (auto-generated if empty)")
_ = root.MarkFlagRequired("homeserver")
_ = root.MarkFlagRequired("username")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// createUser calls PUT /_synapse/admin/v2/users/@user:server
func createUser(homeserver, adminToken, userID, displayname, password string) error {
body := map[string]any{
"password": password,
"admin": false,
"deactivated": false,
}
if displayname != "" {
body["displayname"] = displayname
}
raw, _ := json.Marshal(body)
url := fmt.Sprintf("%s/_synapse/admin/v2/users/%s", homeserver, userID)
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("HTTP PUT %s: %w", url, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("admin API returned %d: %s", resp.StatusCode, respBody)
}
return nil
}
// loginAs calls POST /_matrix/client/v3/login with the bot credentials.
func loginAs(homeserver, username, password string) (token, deviceID string, err error) {
body := map[string]any{
"type": "m.login.password",
"identifier": map[string]string{
"type": "m.id.user",
"user": username,
},
"password": password,
}
raw, _ := json.Marshal(body)
url := homeserver + "/_matrix/client/v3/login"
resp, err := http.Post(url, "application/json", bytes.NewReader(raw))
if err != nil {
return "", "", fmt.Errorf("HTTP POST %s: %w", url, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("login returned %d: %s", resp.StatusCode, respBody)
}
var result struct {
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", "", fmt.Errorf("parse login response: %w", err)
}
return result.AccessToken, result.DeviceID, nil
}
// generatePassword creates a random-enough password for the bot account.
func generatePassword() string {
// Simple: use os.ReadFile on /dev/urandom, encode hex
f, err := os.Open("/dev/urandom")
if err != nil {
return "agent-bot-default-please-change"
}
defer f.Close()
buf := make([]byte, 24)
_, _ = io.ReadFull(f, buf)
return fmt.Sprintf("%x", buf)
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# _common.sh — sourced by all dev-scripts. Do not run directly.
set -euo pipefail
# ── Colores ────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GRN='\033[0;32m'
YLW='\033[0;33m'
BLU='\033[0;34m'
DIM='\033[2m'
RST='\033[0m'
ok() { echo -e "${GRN}${RST} $*"; }
info() { echo -e "${BLU}${RST} $*"; }
warn() { echo -e "${YLW}!${RST} $*"; }
fail() { echo -e "${RED}${RST} $*" >&2; exit 1; }
dim() { echo -e "${DIM}$*${RST}"; }
# ── Repo root ──────────────────────────────────────────────────────────────
# Scripts can be called from any directory; we always operate from repo root.
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
# ── .env loader ───────────────────────────────────────────────────────────
load_env() {
local env_file="${1:-.env}"
[[ -f "$env_file" ]] || fail ".env not found at $REPO_ROOT/$env_file — copy .env.example and fill it in"
# Export only lines that are KEY=VALUE (skip comments and blanks)
set -o allexport
# shellcheck disable=SC1090
source <(grep -E '^[A-Z_]+=.+' "$env_file")
set +o allexport
}
# ── Go tooling ─────────────────────────────────────────────────────────────
GO=${GO_BIN:-go}
export PATH="$PATH:/usr/local/go/bin"
command -v "$GO" &>/dev/null || fail "go not found — install Go or set GO_BIN"
# ── Process helpers ────────────────────────────────────────────────────────
RUN_DIR="$REPO_ROOT/run"
mkdir -p "$RUN_DIR"
pid_file() { echo "$RUN_DIR/$1.pid"; }
log_file() { echo "$RUN_DIR/$1.log"; }
read_pid() {
local f; f="$(pid_file "$1")"
[[ -f "$f" ]] && cat "$f" || echo 0
}
is_running() {
local pid; pid="$(read_pid "$1")"
[[ "$pid" -gt 0 ]] && kill -0 "$pid" 2>/dev/null
}
agent_status() {
local id="$1" enabled="$2"
if [[ "$enabled" != "true" ]]; then
echo "disabled"
elif is_running "$id"; then
echo "running"
else
echo "stopped"
fi
}
# ── Agent discovery ────────────────────────────────────────────────────────
# Prints: id|version|enabled|description (one line per agent)
list_agents_raw() {
for cfg in agents/*/config.yaml; do
[[ -f "$cfg" ]] || continue
local id version enabled desc
id=$(grep -m1 '^ id:' "$cfg" | awk '{print $2}')
version=$(grep -m1 '^ version:' "$cfg" | awk '{print $2}' | tr -d '"')
enabled=$(grep -m1 '^ enabled:' "$cfg" | awk '{print $2}')
desc=$(grep -m1 '^ description:' "$cfg" | cut -d'"' -f2)
[[ -n "$id" ]] && echo "${id}|${version}|${enabled}|${desc}|${cfg}"
done
}
# ── Usage helper ──────────────────────────────────────────────────────────
need_arg() {
[[ -n "${1:-}" ]] || { echo "Usage: $0 <agent-id>"; exit 1; }
}
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# list.sh — muestra todos los agentes y su estado actual
# Uso: ./dev-scripts/list.sh
source "$(dirname "$0")/_common.sh"
printf "%-22s %-12s %-8s %s\n" "ID" "STATUS" "VERSION" "DESCRIPTION"
printf '%s\n' "$(printf '─%.0s' {1..70})"
while IFS='|' read -r id version enabled desc _cfg; do
status=$(agent_status "$id" "$enabled")
case "$status" in
running) label="${GRN}● running${RST}" ;;
stopped) label="${DIM}○ stopped${RST}" ;;
disabled) label="${YLW} disabled${RST}" ;;
*) label="$status" ;;
esac
# Truncate description
[[ ${#desc} -gt 38 ]] && desc="${desc:0:37}"
printf "%-22s " "$id"
printf "${label}"
printf " %-8s %s\n" "$version" "$desc"
done < <(list_agents_raw)
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# logs.sh — sigue los logs de uno o todos los agentes
#
# Uso:
# ./dev-scripts/logs.sh # tail -f de todos los logs activos
# ./dev-scripts/logs.sh assistant-bot # solo ese agente
# ./dev-scripts/logs.sh assistant-bot 100 # últimas 100 líneas
source "$(dirname "$0")/_common.sh"
TARGET="${1:-}"
LINES="${2:-50}"
log_files=()
while IFS='|' read -r id _version _enabled _desc _cfg; do
[[ -n "$TARGET" && "$id" != "$TARGET" ]] && continue
local_log="$(log_file "$id")"
[[ -f "$local_log" ]] && log_files+=("$local_log")
done < <(list_agents_raw)
if [[ "${#log_files[@]}" -eq 0 ]]; then
[[ -n "$TARGET" ]] && fail "No hay logs para '$TARGET' (¿ha sido iniciado alguna vez?)"
fail "No hay logs todavía — inicia algún agente primero"
fi
info "Siguiendo logs: ${log_files[*]}"
dim " Ctrl+C para salir"
echo ""
tail -n "$LINES" -f "${log_files[@]}"
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env bash
# new-agent.sh — genera el scaffold de un nuevo agente
#
# Uso:
# ./dev-scripts/new-agent.sh <agent-id> [displayname]
#
# Ejemplo:
# ./dev-scripts/new-agent.sh monitor-bot "Monitor Agent"
#
# Crea:
# agents/<agent-id>/config.yaml (basado en el assistant como plantilla)
# agents/<agent-id>/agent.go (reglas puras vacías, listo para extender)
# agents/<agent-id>/prompts/ (directorio para system prompt)
# agents/<agent-id>/data/ (directorio de datos, en .gitignore)
#
# También te recuerda los dos pasos manuales que quedan.
source "$(dirname "$0")/_common.sh"
load_env
need_arg "${1:-}"
ID="$1"
DISPLAYNAME="${2:-$ID}"
PACKAGE="$(echo "$ID" | tr '-' '_' | sed 's/_bot//')" # "monitor-bot" → "monitor"
DIR="agents/$ID"
[[ -d "$DIR" ]] && fail "Ya existe agents/$ID — ¿ya fue creado?"
info "Creando scaffold para $ID..."
mkdir -p "$DIR/prompts" "$DIR/data"
# ── config.yaml ────────────────────────────────────────────────────────────
cat > "$DIR/config.yaml" <<YAML
# ============================================
# IDENTIDAD
# ============================================
agent:
id: $ID
name: "$DISPLAYNAME"
version: "1.0.0"
enabled: true
description: "Descripción del agente $DISPLAYNAME"
tags: [$(echo "$ID" | tr '-' ',')]
# ============================================
# PERSONALIDAD Y COMPORTAMIENTO
# ============================================
personality:
tone: friendly
verbosity: concise
language: es
languages_supported: [es, en]
emoji_style: minimal
prefix: "🤖"
error_style: helpful
templates:
greeting: "Hola, soy $DISPLAYNAME. ¿En qué puedo ayudarte?"
unknown_command: "No reconozco ese comando. Escríbeme directamente."
permission_denied: "No tengo permiso para hacer eso."
error: "Algo salió mal: {{.Error}}"
success: "{{.Summary}}"
busy: "Procesando, dame un momento..."
behavior:
proactive: false
ask_confirmation: false
show_reasoning: false
thread_replies: true
typing_indicator: true
acknowledge_receipt: false
# ============================================
# LLM
# ============================================
llm:
primary:
provider: openai
model: gpt-4o
api_key_env: OPENAI_API_KEY
base_url: ""
max_tokens: 4096
temperature: 0.7
fallback:
provider: ""
model: ""
api_key_env: ""
base_url: ""
max_tokens: 0
temperature: 0
reasoning:
system_prompt_file: "prompts/system.md"
context_window: 16384
memory_messages: 20
tool_use:
enabled: false
max_iterations: 3
parallel_calls: false
rate_limit:
requests_per_minute: 30
tokens_per_minute: 100000
concurrent_requests: 3
# ============================================
# TOOLS — ajustar según necesidades del agente
# ============================================
tools:
ssh:
enabled: false
allowed_targets: []
forbidden_commands: []
timeout: 0s
max_concurrent: 0
require_confirmation: []
http:
enabled: false
allowed_domains: []
timeout: 0s
max_retries: 0
scripts:
enabled: false
scripts_dir: ""
allowed: []
timeout: 0s
sandbox: false
file_ops:
enabled: false
allowed_paths: []
read_only: true
mcp:
enabled: false
servers: []
expose:
port: 0
tools: []
# ============================================
# MATRIX
# ============================================
matrix:
homeserver: "${MATRIX_HOMESERVER}"
user_id: "@$ID:${MATRIX_SERVER_NAME}"
access_token_env: MATRIX_TOKEN_$(echo "$ID" | tr '[:lower:]-' '[:upper:]_')
device_id: "$(echo "$ID" | tr '[:lower:]-' '[:upper:]_')01"
encryption:
enabled: false
store_path: "./data/crypto/"
trust_mode: tofu
rooms:
listen: []
respond: []
admin: []
filters:
command_prefix: "!"
mention_respond: true
dm_respond: true
ignore_bots: true
ignore_users: []
min_power_level: 0
# ============================================
# INTER-AGENTES
# ============================================
agents:
peers: []
delegation:
enabled: false
can_delegate_to: []
can_receive_from: []
max_delegation_depth: 1
timeout: 30s
protocol:
format: json
channel: matrix
heartbeat_interval: 60s
# ============================================
# SSH
# ============================================
ssh:
defaults:
user: ""
port: 22
key_file_env: ""
known_hosts: ""
keepalive_interval: 0s
timeout: 0s
targets: {}
# ============================================
# SEGURIDAD
# ============================================
security:
roles:
admin:
users: ["@admin:\${MATRIX_SERVER_NAME}"]
actions: ["*"]
user:
users: ["*"]
actions: ["help"]
audit:
enabled: false
log_file: "./data/audit.log"
log_to_room: ""
include: []
secrets:
provider: env
# ============================================
# SCHEDULING
# ============================================
schedules: []
# ============================================
# OBSERVABILIDAD
# ============================================
observability:
logging:
level: info
format: json
output: stdout
file: "./data/$ID.log"
metrics:
enabled: false
port: 0
path: /metrics
export: prometheus
health:
enabled: true
port: 0
path: /healthz
tracing:
enabled: false
provider: ""
endpoint: ""
# ============================================
# RESILIENCIA
# ============================================
resilience:
circuit_breaker:
failure_threshold: 5
timeout: 30s
half_open_max: 2
retry:
max_attempts: 2
backoff: exponential
initial_delay: 1s
max_delay: 10s
shutdown:
timeout: 10s
drain_messages: true
save_state: false
state_file: ""
queue:
enabled: true
max_size: 50
priority_users: ["@admin:\${MATRIX_SERVER_NAME}"]
# ============================================
# ALMACENAMIENTO
# ============================================
storage:
state:
backend: sqlite
path: "./data/$ID.db"
cache:
enabled: true
backend: memory
ttl: 5m
max_entries: 200
history:
backend: sqlite
path: "./data/history.db"
retention: 168h
YAML
# ── agent.go ───────────────────────────────────────────────────────────────
cat > "$DIR/agent.go" <<GO
// Package $PACKAGE defines the pure rules for the $DISPLAYNAME.
package $PACKAGE
import "github.com/enmanuel/agents/pkg/decision"
// Rules returns the decision rules for the $ID.
func Rules() []decision.Rule {
return []decision.Rule{
{
Name: "help",
Match: decision.MatchCommand("help"),
Actions: []decision.Action{{
Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{
Content: "Soy $DISPLAYNAME. Escríbeme lo que necesitas.",
},
}},
},
// Catch-all: DMs y menciones van al LLM
{
Name: "llm-fallback",
Match: func(ctx decision.MessageContext) bool {
return ctx.IsDirectMsg || ctx.IsMention
},
Actions: []decision.Action{{
Kind: decision.ActionKindLLM,
LLM: &decision.LLMAction{},
}},
},
}
}
GO
# ── system prompt ──────────────────────────────────────────────────────────
cat > "$DIR/prompts/system.md" <<MD
# $DISPLAYNAME — System Prompt
Eres $DISPLAYNAME. Describe aquí el rol, capacidades y restricciones del agente.
## Rol
...
## Capacidades
...
## Restricciones
...
MD
ok "Scaffold creado en $DIR/"
echo ""
# ── Pasos siguientes ──────────────────────────────────────────────────────
echo -e "${YLW}Quedan 2 pasos manuales:${RST}"
echo ""
echo -e " ${BLU}1.${RST} Añade una línea en ${BLU}cmd/launcher/main.go${RST}:"
echo ""
echo -e ' import ('
echo -e " ${GRN}${PACKAGE}agent \"github.com/enmanuel/agents/agents/$ID\"${RST}"
echo -e ' )'
echo ""
echo -e ' var rulesRegistry = map[string]func() []decision.Rule{'
echo -e " ${GRN}\"$ID\": ${PACKAGE}agent.Rules,${RST}"
echo -e ' ...'
echo -e ' }'
echo ""
echo -e " ${BLU}2.${RST} Registra el bot en Matrix y añade el token a .env:"
echo ""
echo -e " ${DIM}./dev-scripts/register.sh $ID \"$DISPLAYNAME\"${RST}"
echo ""
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# register.sh — registra un nuevo bot en el servidor Matrix via Synapse admin API
#
# Uso:
# ./dev-scripts/register.sh <username> [displayname] [env-var-name]
#
# Ejemplos:
# ./dev-scripts/register.sh assistant-bot "Assistant" MATRIX_TOKEN_ASSISTANT
# ./dev-scripts/register.sh devops-bot "DevOps Agent" MATRIX_TOKEN_DEVOPS
#
# Requiere en .env:
# MATRIX_ADMIN_TOKEN=syt_...
# MATRIX_HOMESERVER=https://...
source "$(dirname "$0")/_common.sh"
load_env
need_arg "${1:-}"
USERNAME="$1"
DISPLAYNAME="${2:-$USERNAME}"
ENV_VAR="${3:-MATRIX_TOKEN_$(echo "$USERNAME" | tr '[:lower:]-' '[:upper:]_')}"
[[ -n "${MATRIX_ADMIN_TOKEN:-}" ]] || fail "MATRIX_ADMIN_TOKEN no está en .env"
[[ -n "${MATRIX_HOMESERVER:-}" ]] || fail "MATRIX_HOMESERVER no está en .env"
info "Registrando @${USERNAME}:${MATRIX_SERVER_NAME:-$MATRIX_HOMESERVER}..."
echo ""
"$GO" run ./cmd/register \
--homeserver "$MATRIX_HOMESERVER" \
--username "$USERNAME" \
--displayname "$DISPLAYNAME" \
--env-var "$ENV_VAR"
echo ""
dim " Copia las líneas de arriba a tu .env y luego corre:"
dim " ./dev-scripts/start.sh $USERNAME"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# remove.sh — deshabilita un agente (enabled: false). No borra datos.
#
# Uso:
# ./dev-scripts/remove.sh assistant-bot
source "$(dirname "$0")/_common.sh"
need_arg "${1:-}"
TARGET="$1"
found=false
while IFS='|' read -r id _version _enabled _desc cfg; do
[[ "$id" != "$TARGET" ]] && continue
found=true
# Detener si está corriendo
if is_running "$id"; then
local_pid="$(read_pid "$id")"
info "Deteniendo $id (PID $local_pid)..."
kill -TERM "$local_pid" 2>/dev/null || true
sleep 1
kill -0 "$local_pid" 2>/dev/null && kill -9 "$local_pid" 2>/dev/null || true
rm -f "$(pid_file "$id")"
ok "$id detenido"
fi
# Marcar como disabled en el config (reemplaza solo la primera ocurrencia)
if grep -q 'enabled: true' "$cfg"; then
# sed compatible con Linux y macOS
sed -i 's/enabled: true/enabled: false/' "$cfg"
ok "$id marcado como disabled en $cfg"
else
warn "$id ya estaba marcado como disabled"
fi
dim " Datos preservados en agents/$id/data/"
done < <(list_agents_raw)
"$found" || fail "Agente '$TARGET' no encontrado"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# start.sh — inicia uno o todos los agentes habilitados en background
#
# Uso:
# ./dev-scripts/start.sh # inicia todos los habilitados
# ./dev-scripts/start.sh assistant-bot # inicia uno específico
source "$(dirname "$0")/_common.sh"
load_env
TARGET="${1:-}"
start_agent() {
local id="$1" cfg="$2"
local log; log="$(log_file "$id")"
local pid_f; pid_f="$(pid_file "$id")"
info "Iniciando $id..."
# Lanza el launcher en background, desacoplado del terminal
nohup "$GO" run ./cmd/launcher -c "$cfg" \
>> "$log" 2>&1 &
local pid=$!
echo "$pid" > "$pid_f"
# Espera un momento y verifica que el proceso siga vivo
sleep 1
if kill -0 "$pid" 2>/dev/null; then
ok "$id PID $pid → logs: $log"
else
rm -f "$pid_f"
fail "$id arrancó pero murió — revisa: tail -f $log"
fi
}
started=0
while IFS='|' read -r id version enabled desc cfg; do
# Filtrar por TARGET si se especificó uno
[[ -n "$TARGET" && "$id" != "$TARGET" ]] && continue
if [[ "$enabled" != "true" ]]; then
warn "$id (disabled en config, saltar)"
continue
fi
if is_running "$id"; then
warn "$id (ya corriendo, PID $(read_pid "$id"))"
continue
fi
start_agent "$id" "$cfg"
((started++)) || true
done < <(list_agents_raw)
[[ "$started" -eq 0 && -z "$TARGET" ]] && warn "Ningún agente iniciado."
[[ -n "$TARGET" && "$started" -eq 0 ]] && fail "Agente '$TARGET' no encontrado o ya está corriendo."
true
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# stop.sh — detiene uno o todos los agentes en ejecución
#
# Uso:
# ./dev-scripts/stop.sh # detiene todos los que estén corriendo
# ./dev-scripts/stop.sh assistant-bot # detiene uno específico
source "$(dirname "$0")/_common.sh"
TARGET="${1:-}"
stopped=0
while IFS='|' read -r id _version _enabled _desc _cfg; do
[[ -n "$TARGET" && "$id" != "$TARGET" ]] && continue
if ! is_running "$id"; then
dim " $id (no está corriendo)"
continue
fi
local_pid="$(read_pid "$id")"
kill -TERM "$local_pid" 2>/dev/null || true
# Espera hasta 5s a que muera limpiamente
for _ in {1..10}; do
kill -0 "$local_pid" 2>/dev/null || break
sleep 0.5
done
# SIGKILL si todavía sigue vivo
if kill -0 "$local_pid" 2>/dev/null; then
warn "$id no respondió a SIGTERM, enviando SIGKILL..."
kill -9 "$local_pid" 2>/dev/null || true
fi
rm -f "$(pid_file "$id")"
ok "$id detenido (PID $local_pid)"
((stopped++)) || true
done < <(list_agents_raw)
[[ "$stopped" -eq 0 && -z "$TARGET" ]] && dim "Ningún agente estaba corriendo."
[[ -n "$TARGET" && "$stopped" -eq 0 ]] && fail "Agente '$TARGET' no encontrado o no estaba corriendo."
true
+18
View File
@@ -29,6 +29,24 @@ func Load(path string) (*AgentConfig, error) {
return &cfg, nil
}
// LoadMeta reads only the `agent:` block from a config file without expanding
// env vars or running full validation. Used by agentctl list to show all
// agents regardless of whether their env vars are configured.
func LoadMeta(path string) (*AgentConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var cfg AgentConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
if cfg.Agent.ID == "" {
return nil, fmt.Errorf("agent.id is required")
}
return &cfg, nil
}
// validate applies basic sanity checks.
func validate(cfg *AgentConfig) error {
if cfg.Agent.ID == "" {