From bd8e1432e5fb166c6c1167716aabea2db9b35e95 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Tue, 3 Mar 2026 23:57:13 +0000 Subject: [PATCH] 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. --- .claude/CLAUDE.md | 149 +++++++ .env.example | 43 +- Makefile | 53 +++ README.md | 273 +++++++++++++ agents/assistant/agent.go | 41 ++ agents/assistant/config.yaml | 269 +++++++++++++ agents/assistant/prompts/assistant-system.md | 24 ++ cmd/agentctl/main.go | 388 +++++++++++++++++-- cmd/launcher/main.go | 102 +++-- cmd/register/main.go | 188 +++++++++ dev-scripts/_common.sh | 86 ++++ dev-scripts/list.sh | 27 ++ dev-scripts/logs.sh | 31 ++ dev-scripts/new-agent.sh | 358 +++++++++++++++++ dev-scripts/register.sh | 38 ++ dev-scripts/remove.sh | 41 ++ dev-scripts/start.sh | 61 +++ dev-scripts/stop.sh | 45 +++ internal/config/loader.go | 18 + 19 files changed, 2142 insertions(+), 93 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 agents/assistant/agent.go create mode 100644 agents/assistant/config.yaml create mode 100644 agents/assistant/prompts/assistant-system.md create mode 100644 cmd/register/main.go create mode 100755 dev-scripts/_common.sh create mode 100755 dev-scripts/list.sh create mode 100755 dev-scripts/logs.sh create mode 100755 dev-scripts/new-agent.sh create mode 100755 dev-scripts/register.sh create mode 100755 dev-scripts/remove.sh create mode 100755 dev-scripts/start.sh create mode 100755 dev-scripts/stop.sh diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..d1ab4a7 --- /dev/null +++ b/.claude/CLAUDE.md @@ -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//`: +- `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 "Display Name"` +2. Registrarlo en Matrix: `./dev-scripts/register.sh "Display Name"` +3. Añadir el token al `.env` +4. Añadir una línea en `cmd/launcher/main.go` → `rulesRegistry` +5. Editar `agents//agent.go` con las reglas reales +6. Arrancar: `./dev-scripts/start.sh ` + +## 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 # deshabilitar (sin borrar datos) +./dev-scripts/register.sh [name] # registrar bot en Matrix +./dev-scripts/logs.sh [agent-id] # tail -f de logs +./dev-scripts/new-agent.sh [name] # scaffold completo +``` + +PID files: `run/.pid` | Log files: `run/.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/.pid` y su log en `run/.log`. +`is_running()` usa `kill -0 ` 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_ # 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 diff --git a/.env.example b/.env.example index 39f1f3d..4f0b2de 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a1268e3 --- /dev/null +++ b/Makefile @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2d08f6 --- /dev/null +++ b/README.md @@ -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 +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//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//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/ +``` diff --git a/agents/assistant/agent.go b/agents/assistant/agent.go new file mode 100644 index 0000000..a80bcb7 --- /dev/null +++ b/agents/assistant/agent.go @@ -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{}, + }}, + }, + } +} diff --git a/agents/assistant/config.yaml b/agents/assistant/config.yaml new file mode 100644 index 0000000..ef46cb4 --- /dev/null +++ b/agents/assistant/config.yaml @@ -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 diff --git a/agents/assistant/prompts/assistant-system.md b/agents/assistant/prompts/assistant-system.md new file mode 100644 index 0000000..7d89400 --- /dev/null +++ b/agents/assistant/prompts/assistant-system.md @@ -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. diff --git a/cmd/agentctl/main.go b/cmd/agentctl/main.go index 42b5ffc..32abe55 100644 --- a/cmd/agentctl/main.go +++ b/cmd/agentctl/main.go @@ -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 ", + 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 + 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" +} diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go index 69aa0e1..3596f96 100644 --- a/cmd/launcher/main.go +++ b/cmd/launcher/main.go @@ -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})) } diff --git a/cmd/register/main.go b/cmd/register/main.go new file mode 100644 index 0000000..9401782 --- /dev/null +++ b/cmd/register/main.go @@ -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) +} diff --git a/dev-scripts/_common.sh b/dev-scripts/_common.sh new file mode 100755 index 0000000..b4a4363 --- /dev/null +++ b/dev-scripts/_common.sh @@ -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 "; exit 1; } +} diff --git a/dev-scripts/list.sh b/dev-scripts/list.sh new file mode 100755 index 0000000..0e4fb6e --- /dev/null +++ b/dev-scripts/list.sh @@ -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) diff --git a/dev-scripts/logs.sh b/dev-scripts/logs.sh new file mode 100755 index 0000000..11f4b88 --- /dev/null +++ b/dev-scripts/logs.sh @@ -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[@]}" diff --git a/dev-scripts/new-agent.sh b/dev-scripts/new-agent.sh new file mode 100755 index 0000000..e0a486f --- /dev/null +++ b/dev-scripts/new-agent.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# new-agent.sh — genera el scaffold de un nuevo agente +# +# Uso: +# ./dev-scripts/new-agent.sh [displayname] +# +# Ejemplo: +# ./dev-scripts/new-agent.sh monitor-bot "Monitor Agent" +# +# Crea: +# agents//config.yaml (basado en el assistant como plantilla) +# agents//agent.go (reglas puras vacías, listo para extender) +# agents//prompts/ (directorio para system prompt) +# agents//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" < "$DIR/agent.go" < "$DIR/prompts/system.md" < [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" diff --git a/dev-scripts/remove.sh b/dev-scripts/remove.sh new file mode 100755 index 0000000..897b03b --- /dev/null +++ b/dev-scripts/remove.sh @@ -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" diff --git a/dev-scripts/start.sh b/dev-scripts/start.sh new file mode 100755 index 0000000..85eaca6 --- /dev/null +++ b/dev-scripts/start.sh @@ -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 diff --git a/dev-scripts/stop.sh b/dev-scripts/stop.sh new file mode 100755 index 0000000..3325e1e --- /dev/null +++ b/dev-scripts/stop.sh @@ -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 diff --git a/internal/config/loader.go b/internal/config/loader.go index 6f84356..6e88ba3 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -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 == "" {