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:
@@ -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
@@ -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=
|
||||
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
```
|
||||
@@ -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{},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Executable
+86
@@ -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; }
|
||||
}
|
||||
Executable
+27
@@ -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)
|
||||
Executable
+31
@@ -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[@]}"
|
||||
Executable
+358
@@ -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 ""
|
||||
Executable
+38
@@ -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"
|
||||
Executable
+41
@@ -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"
|
||||
Executable
+61
@@ -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
|
||||
Executable
+45
@@ -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
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user