Repo iniciado
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# ============================================================
|
||||
# Copy this to .env and fill in your values.
|
||||
# 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_TOKEN_ASSISTANT=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-...
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# SSH
|
||||
SSH_PRIVATE_KEY_PATH=/home/deploy/.ssh/id_ed25519
|
||||
SSH_MONITOR_KEY_PATH=/home/monitor/.ssh/id_ed25519
|
||||
|
||||
# Infrastructure hosts
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
*.db
|
||||
*.log
|
||||
data/
|
||||
bin/
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package devops defines the rules and composition for the devops agent.
|
||||
package devops
|
||||
|
||||
import (
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
"github.com/enmanuel/agents/pkg/tools"
|
||||
)
|
||||
|
||||
// Rules returns the decision rules for the devops agent.
|
||||
// These are pure data — no side effects.
|
||||
func Rules() []decision.Rule {
|
||||
return []decision.Rule{
|
||||
{
|
||||
Name: "help",
|
||||
Match: decision.MatchCommand("help"),
|
||||
Actions: []decision.Action{{
|
||||
Kind: decision.ActionKindReply,
|
||||
Reply: &decision.ReplyAction{
|
||||
Content: "**DevOps Agent** — comandos disponibles:\n" +
|
||||
"- `!status <target>` — estado del target\n" +
|
||||
"- `!deploy <env>` — deployment en el environment\n" +
|
||||
"- `!rollback <env>` — rollback del último deploy\n" +
|
||||
"- `!logs <target>` — últimas líneas de log\n" +
|
||||
"- `!healthcheck` — health check de producción",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "healthcheck",
|
||||
Match: decision.MatchCommand("healthcheck"),
|
||||
Actions: []decision.Action{{
|
||||
Kind: decision.ActionKindSSH,
|
||||
SSH: &tools.SSHCommandSpec{
|
||||
Target: "production",
|
||||
Command: "/opt/scripts/healthcheck.sh",
|
||||
Timeout: "30s",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
Match: decision.MatchCommand("status"),
|
||||
Actions: []decision.Action{{
|
||||
Kind: decision.ActionKindSSH,
|
||||
SSH: &tools.SSHCommandSpec{
|
||||
Target: "monitoring",
|
||||
Command: "systemctl status --no-pager",
|
||||
Timeout: "15s",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
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: "cd /app && git pull origin main && systemctl restart app",
|
||||
Timeout: "60s",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "deploy-production",
|
||||
Match: decision.And(
|
||||
decision.MatchCommand("deploy"),
|
||||
decision.MatchMinPowerLevel(50),
|
||||
func(ctx decision.MessageContext) bool {
|
||||
return len(ctx.Args) > 0 && ctx.Args[0] == "production"
|
||||
},
|
||||
),
|
||||
Actions: []decision.Action{{
|
||||
Kind: decision.ActionKindSSH,
|
||||
SSH: &tools.SSHCommandSpec{
|
||||
Target: "production",
|
||||
Command: "cd /app && git pull origin main && systemctl restart app",
|
||||
Timeout: "120s",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "logs",
|
||||
Match: decision.MatchCommand("logs"),
|
||||
Actions: []decision.Action{{
|
||||
Kind: decision.ActionKindSSH,
|
||||
SSH: &tools.SSHCommandSpec{
|
||||
Target: "production",
|
||||
Command: "journalctl -u app -n 50 --no-pager",
|
||||
Timeout: "15s",
|
||||
},
|
||||
}},
|
||||
},
|
||||
// Fallback: anything else goes to LLM
|
||||
{
|
||||
Name: "llm-fallback",
|
||||
Match: decision.And(
|
||||
decision.MatchAny(),
|
||||
func(ctx decision.MessageContext) bool {
|
||||
return ctx.Command == "" && (ctx.IsMention || ctx.IsDirectMsg)
|
||||
},
|
||||
),
|
||||
Actions: []decision.Action{{
|
||||
Kind: decision.ActionKindLLM,
|
||||
LLM: &decision.LLMAction{},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
# ============================================
|
||||
# IDENTIDAD
|
||||
# ============================================
|
||||
agent:
|
||||
id: devops-bot
|
||||
name: "DevOps Agent"
|
||||
version: "1.0.0"
|
||||
enabled: true
|
||||
description: "Gestiona deployments, monitoreo y salud de infraestructura"
|
||||
tags: [devops, infrastructure, deployment]
|
||||
|
||||
# ============================================
|
||||
# PERSONALIDAD Y COMPORTAMIENTO
|
||||
# ============================================
|
||||
personality:
|
||||
tone: direct # direct | friendly | formal | casual | technical
|
||||
verbosity: concise # minimal | concise | detailed | verbose
|
||||
language: es
|
||||
languages_supported: [es, en]
|
||||
emoji_style: moderate # none | minimal | moderate | heavy
|
||||
prefix: "🔧"
|
||||
error_style: helpful # terse | helpful | detailed
|
||||
|
||||
templates:
|
||||
greeting: "Listo para operar. ¿Qué necesitas?"
|
||||
unknown_command: "No reconozco eso. Usa `!help` para ver comandos."
|
||||
permission_denied: "No tienes permisos para eso."
|
||||
error: "Algo falló: {{.Error}}"
|
||||
success: "Hecho. {{.Summary}}"
|
||||
busy: "Estoy ejecutando otra tarea ahora. Espera o usa `!queue`."
|
||||
|
||||
behavior:
|
||||
proactive: false
|
||||
ask_confirmation: true
|
||||
show_reasoning: false
|
||||
thread_replies: true
|
||||
typing_indicator: true
|
||||
acknowledge_receipt: true
|
||||
|
||||
# ============================================
|
||||
# LLM — CONEXIÓN Y RAZONAMIENTO
|
||||
# ============================================
|
||||
llm:
|
||||
primary:
|
||||
provider: anthropic
|
||||
model: claude-sonnet-4-20250514
|
||||
api_key_env: ANTHROPIC_API_KEY
|
||||
base_url: ""
|
||||
max_tokens: 4096
|
||||
temperature: 0.3
|
||||
|
||||
fallback:
|
||||
provider: ollama
|
||||
model: llama3
|
||||
base_url: "http://localhost:11434/v1"
|
||||
max_tokens: 2048
|
||||
temperature: 0.5
|
||||
|
||||
reasoning:
|
||||
system_prompt_file: "prompts/devops-system.md"
|
||||
context_window: 8192
|
||||
memory_messages: 20
|
||||
|
||||
tool_use:
|
||||
enabled: true
|
||||
max_iterations: 5
|
||||
parallel_calls: false
|
||||
|
||||
rate_limit:
|
||||
requests_per_minute: 30
|
||||
tokens_per_minute: 100000
|
||||
concurrent_requests: 3
|
||||
|
||||
# ============================================
|
||||
# TOOLS — CAPACIDADES DISPONIBLES
|
||||
# ============================================
|
||||
tools:
|
||||
ssh:
|
||||
enabled: true
|
||||
allowed_targets: [production, staging, monitoring]
|
||||
forbidden_commands:
|
||||
- "rm -rf /"
|
||||
- "dd if="
|
||||
- "mkfs"
|
||||
timeout: 30s
|
||||
max_concurrent: 3
|
||||
require_confirmation:
|
||||
- production
|
||||
|
||||
http:
|
||||
enabled: true
|
||||
allowed_domains:
|
||||
- "api.github.com"
|
||||
- "api.gitea.internal"
|
||||
- "grafana.internal"
|
||||
timeout: 15s
|
||||
max_retries: 2
|
||||
|
||||
scripts:
|
||||
enabled: true
|
||||
scripts_dir: "./scripts/"
|
||||
allowed:
|
||||
- "deploy.sh"
|
||||
- "healthcheck.sh"
|
||||
- "rollback.sh"
|
||||
timeout: 120s
|
||||
sandbox: false
|
||||
|
||||
file_ops:
|
||||
enabled: false
|
||||
allowed_paths: ["/var/log/", "/tmp/reports/"]
|
||||
read_only: true
|
||||
|
||||
mcp:
|
||||
enabled: true
|
||||
servers:
|
||||
- name: github
|
||||
url: "stdio://mcp-github"
|
||||
tools: ["create_issue", "list_prs", "merge_pr"]
|
||||
- name: filesystem
|
||||
url: "stdio://mcp-filesystem"
|
||||
tools: ["read_file", "list_dir"]
|
||||
expose:
|
||||
port: 9100
|
||||
tools: ["deploy", "status", "rollback"]
|
||||
|
||||
# ============================================
|
||||
# MATRIX — CONEXIÓN Y ROOMS
|
||||
# ============================================
|
||||
matrix:
|
||||
homeserver: "${MATRIX_HOMESERVER}"
|
||||
user_id: "@devops-bot:${MATRIX_SERVER_NAME}"
|
||||
access_token_env: MATRIX_TOKEN_DEVOPS
|
||||
device_id: "DEVOPSBOT01"
|
||||
|
||||
encryption:
|
||||
enabled: false # habilitar cuando E2EE esté configurado
|
||||
store_path: "./data/crypto/"
|
||||
trust_mode: tofu
|
||||
|
||||
rooms:
|
||||
listen:
|
||||
- "${MATRIX_ROOM_DEVOPS}"
|
||||
- "${MATRIX_ROOM_ALERTS}"
|
||||
respond:
|
||||
- "${MATRIX_ROOM_DEVOPS}"
|
||||
- "${MATRIX_ROOM_LOGS}"
|
||||
admin:
|
||||
- "${MATRIX_ROOM_ADMIN}"
|
||||
|
||||
filters:
|
||||
command_prefix: "!"
|
||||
mention_respond: true
|
||||
dm_respond: true
|
||||
ignore_bots: true
|
||||
ignore_users: []
|
||||
min_power_level: 0
|
||||
|
||||
# ============================================
|
||||
# COMUNICACIÓN INTER-AGENTES
|
||||
# ============================================
|
||||
agents:
|
||||
peers:
|
||||
- id: monitor-bot
|
||||
capabilities: [alerts, metrics, healthcheck]
|
||||
room: "${MATRIX_ROOM_AGENTS_INTERNAL}"
|
||||
- id: assistant-bot
|
||||
capabilities: [search, summarize, translate]
|
||||
room: "${MATRIX_ROOM_AGENTS_INTERNAL}"
|
||||
|
||||
delegation:
|
||||
enabled: true
|
||||
can_delegate_to: [monitor-bot, assistant-bot]
|
||||
can_receive_from: [assistant-bot]
|
||||
max_delegation_depth: 2
|
||||
timeout: 60s
|
||||
|
||||
protocol:
|
||||
format: json
|
||||
channel: matrix
|
||||
heartbeat_interval: 30s
|
||||
|
||||
# ============================================
|
||||
# SSH — INVENTARIO DE SERVIDORES
|
||||
# ============================================
|
||||
ssh:
|
||||
defaults:
|
||||
user: deploy
|
||||
port: 22
|
||||
key_file_env: SSH_PRIVATE_KEY_PATH
|
||||
known_hosts: "./data/known_hosts"
|
||||
keepalive_interval: 15s
|
||||
timeout: 10s
|
||||
|
||||
targets:
|
||||
production:
|
||||
hosts: ["${PROD_HOST_1}", "${PROD_HOST_2}"]
|
||||
user: deploy
|
||||
jump_host: "${BASTION_HOST}"
|
||||
|
||||
staging:
|
||||
hosts: ["${STAGING_HOST}"]
|
||||
user: deploy
|
||||
|
||||
monitoring:
|
||||
hosts: ["${MONITORING_HOST}"]
|
||||
user: monitor
|
||||
key_file_env: SSH_MONITOR_KEY_PATH
|
||||
|
||||
# ============================================
|
||||
# PERMISOS Y SEGURIDAD
|
||||
# ============================================
|
||||
security:
|
||||
roles:
|
||||
admin:
|
||||
users: ["@admin:${MATRIX_SERVER_NAME}"]
|
||||
actions: ["*"]
|
||||
developer:
|
||||
users: ["@dev1:${MATRIX_SERVER_NAME}", "@dev2:${MATRIX_SERVER_NAME}"]
|
||||
actions: ["deploy:staging", "status:*", "logs:*"]
|
||||
viewer:
|
||||
users: ["*"]
|
||||
actions: ["status:*", "help"]
|
||||
|
||||
audit:
|
||||
enabled: true
|
||||
log_file: "./data/audit.log"
|
||||
log_to_room: "${MATRIX_ROOM_AUDIT}"
|
||||
include: [ssh, deploy, config_change]
|
||||
|
||||
secrets:
|
||||
provider: env
|
||||
|
||||
# ============================================
|
||||
# SCHEDULING Y TAREAS AUTOMÁTICAS
|
||||
# ============================================
|
||||
schedules:
|
||||
- name: healthcheck
|
||||
cron: "*/5 * * * *"
|
||||
action:
|
||||
kind: ssh
|
||||
target: production
|
||||
command: "/opt/scripts/healthcheck.sh"
|
||||
on_failure:
|
||||
notify_room: "${MATRIX_ROOM_ALERTS}"
|
||||
escalate_to: "@admin:${MATRIX_SERVER_NAME}"
|
||||
|
||||
- name: daily-report
|
||||
cron: "0 9 * * *"
|
||||
action:
|
||||
kind: script
|
||||
script: "daily-report.sh"
|
||||
output_room: "${MATRIX_ROOM_DEVOPS}"
|
||||
|
||||
- name: backup-check
|
||||
cron: "0 */6 * * *"
|
||||
action:
|
||||
kind: ssh
|
||||
target: production
|
||||
command: "/opt/scripts/check-backups.sh"
|
||||
on_failure:
|
||||
notify_room: "${MATRIX_ROOM_ALERTS}"
|
||||
escalate_to: "@admin:${MATRIX_SERVER_NAME}"
|
||||
|
||||
# ============================================
|
||||
# OBSERVABILIDAD
|
||||
# ============================================
|
||||
observability:
|
||||
logging:
|
||||
level: info
|
||||
format: json
|
||||
output: stdout
|
||||
file: "./data/agent.log"
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
port: 9090
|
||||
path: /metrics
|
||||
export: prometheus
|
||||
|
||||
health:
|
||||
enabled: true
|
||||
port: 8080
|
||||
path: /healthz
|
||||
|
||||
tracing:
|
||||
enabled: false
|
||||
provider: jaeger
|
||||
endpoint: "http://jaeger:14268/api/traces"
|
||||
|
||||
# ============================================
|
||||
# RESILIENCIA
|
||||
# ============================================
|
||||
resilience:
|
||||
circuit_breaker:
|
||||
failure_threshold: 5
|
||||
timeout: 30s
|
||||
half_open_max: 2
|
||||
|
||||
retry:
|
||||
max_attempts: 3
|
||||
backoff: exponential
|
||||
initial_delay: 1s
|
||||
max_delay: 30s
|
||||
|
||||
shutdown:
|
||||
timeout: 15s
|
||||
drain_messages: true
|
||||
save_state: true
|
||||
state_file: "./data/state.json"
|
||||
|
||||
queue:
|
||||
enabled: true
|
||||
max_size: 50
|
||||
priority_users: ["@admin:${MATRIX_SERVER_NAME}"]
|
||||
|
||||
# ============================================
|
||||
# ALMACENAMIENTO Y ESTADO
|
||||
# ============================================
|
||||
storage:
|
||||
state:
|
||||
backend: sqlite
|
||||
path: "./data/agent.db"
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
backend: memory
|
||||
ttl: 10m
|
||||
max_entries: 500
|
||||
|
||||
history:
|
||||
backend: sqlite
|
||||
path: "./data/history.db"
|
||||
retention: 720h # 30 days
|
||||
@@ -0,0 +1,24 @@
|
||||
# DevOps Agent — System Prompt
|
||||
|
||||
Eres un agente de DevOps especializado en gestión de infraestructura y deployments.
|
||||
|
||||
## Rol y responsabilidades
|
||||
- Ejecutar deployments en staging y producción con confirmación cuando sea necesario
|
||||
- Monitorear el estado de los servicios y reportar anomalías
|
||||
- Ejecutar scripts de mantenimiento y salud del sistema
|
||||
- Coordinar con otros agentes (monitor-bot, assistant-bot) cuando la tarea lo requiera
|
||||
|
||||
## Estilo de comunicación
|
||||
- Respuestas directas y técnicas
|
||||
- Usar listas para pasos de procedimientos
|
||||
- Reportar siempre exit codes y stderr relevante
|
||||
- Confirmar antes de acciones destructivas en producción
|
||||
|
||||
## Restricciones
|
||||
- NUNCA ejecutar comandos que modifiquen datos de usuarios sin confirmación explícita
|
||||
- NUNCA ejecutar comandos que puedan causar downtime sin coordinación previa
|
||||
- Rechazar solicitudes de acceso a sistemas no listados en el inventario SSH
|
||||
- Reportar inmediatamente cualquier error inesperado
|
||||
|
||||
## Formato de respuesta
|
||||
Usa markdown cuando sea útil. Para output de comandos, usa bloques de código con el shell apropiado.
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package agents defines the Agent runtime that ties core and shell together.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"maunium.net/go/mautrix/event"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
coretypes "github.com/enmanuel/agents/pkg/llm"
|
||||
"github.com/enmanuel/agents/pkg/personality"
|
||||
"github.com/enmanuel/agents/shell/effects"
|
||||
shelllm "github.com/enmanuel/agents/shell/llm"
|
||||
"github.com/enmanuel/agents/shell/matrix"
|
||||
"github.com/enmanuel/agents/shell/ssh"
|
||||
)
|
||||
|
||||
// Agent is the assembled runtime: pure core + impure shell.
|
||||
type Agent struct {
|
||||
cfg *config.AgentConfig
|
||||
personality personality.Personality
|
||||
rules []decision.Rule
|
||||
llm coretypes.CompleteFunc
|
||||
matrix *matrix.Client
|
||||
runner *effects.Runner
|
||||
listener *matrix.Listener
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New assembles an Agent from its config, rules, and logger.
|
||||
func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*Agent, error) {
|
||||
// Matrix client
|
||||
matrixClient, err := matrix.New(cfg.Matrix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix client: %w", err)
|
||||
}
|
||||
|
||||
// SSH executor
|
||||
sshExec := ssh.NewExecutor(cfg.SSH)
|
||||
|
||||
// LLM client
|
||||
primaryLLM, err := shelllm.FromConfig(cfg.LLM.Primary)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("primary LLM: %w", err)
|
||||
}
|
||||
|
||||
var llmFunc coretypes.CompleteFunc = primaryLLM
|
||||
if cfg.LLM.Fallback.Provider != "" {
|
||||
fallbackLLM, err := shelllm.FromConfig(cfg.LLM.Fallback)
|
||||
if err != nil {
|
||||
logger.Warn("fallback LLM config error", "err", err)
|
||||
} else {
|
||||
llmFunc = shelllm.WithFallback(primaryLLM, fallbackLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// Effects runner
|
||||
runner := effects.NewRunner(matrixClient, sshExec, logger)
|
||||
|
||||
a := &Agent{
|
||||
cfg: cfg,
|
||||
rules: rules,
|
||||
llm: llmFunc,
|
||||
matrix: matrixClient,
|
||||
runner: runner,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Matrix event listener
|
||||
a.listener = matrix.NewListener(matrixClient, cfg.Matrix, a.handleEvent, logger)
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Run starts the agent sync loop. Blocks until ctx is cancelled.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
a.logger.Info("agent starting", "id", a.cfg.Agent.ID, "name", a.cfg.Agent.Name)
|
||||
return a.listener.Run(ctx)
|
||||
}
|
||||
|
||||
// handleEvent is called by the matrix Listener for each filtered incoming event.
|
||||
func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext, evt *event.Event) {
|
||||
if a.cfg.Personality.Behavior.TypingIndicator {
|
||||
_ = a.matrix.SendTyping(ctx, evt.RoomID.String(), true)
|
||||
defer a.matrix.SendTyping(ctx, evt.RoomID.String(), false)
|
||||
}
|
||||
|
||||
actions := decision.Evaluate(msgCtx, a.rules)
|
||||
|
||||
// If no rules matched and the message mentions the bot or is a DM, use LLM.
|
||||
if len(actions) == 0 && (msgCtx.IsMention || msgCtx.IsDirectMsg) {
|
||||
actions = []decision.Action{{
|
||||
Kind: decision.ActionKindLLM,
|
||||
LLM: &decision.LLMAction{ContextKey: msgCtx.RoomID},
|
||||
}}
|
||||
}
|
||||
|
||||
if len(actions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Expand LLM actions inline (simplified — real impl would maintain conversation state)
|
||||
expanded := make([]decision.Action, 0, len(actions))
|
||||
for _, act := range actions {
|
||||
if act.Kind == decision.ActionKindLLM {
|
||||
reply, err := a.runLLM(ctx, msgCtx)
|
||||
if err != nil {
|
||||
a.logger.Error("llm error", "err", err)
|
||||
expanded = append(expanded, decision.Action{
|
||||
Kind: decision.ActionKindReply,
|
||||
Reply: &decision.ReplyAction{Content: "Sorry, I encountered an error."},
|
||||
})
|
||||
} else {
|
||||
expanded = append(expanded, decision.Action{
|
||||
Kind: decision.ActionKindReply,
|
||||
Reply: &decision.ReplyAction{Content: reply},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
expanded = append(expanded, act)
|
||||
}
|
||||
}
|
||||
|
||||
a.runner.Execute(ctx, evt.RoomID.String(), expanded)
|
||||
}
|
||||
|
||||
func (a *Agent) runLLM(ctx context.Context, msgCtx decision.MessageContext) (string, error) {
|
||||
req := coretypes.CompletionRequest{
|
||||
Model: a.cfg.LLM.Primary.Model,
|
||||
MaxTokens: a.cfg.LLM.Primary.MaxTokens,
|
||||
Temperature: a.cfg.LLM.Primary.Temperature,
|
||||
SystemPrompt: a.cfg.Agent.Description,
|
||||
Messages: []coretypes.Message{
|
||||
{Role: coretypes.RoleUser, Content: msgCtx.Content},
|
||||
},
|
||||
}
|
||||
resp, err := a.llm(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Content, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Command agentctl is a CLI for inspecting and managing agents.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "agentctl",
|
||||
Short: "Manage and inspect agents",
|
||||
}
|
||||
|
||||
root.AddCommand(
|
||||
listCmd(),
|
||||
validateCmd(),
|
||||
)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func listCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list [config.yaml...]",
|
||||
Short: "List agents from config files",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("provide at least one config file")
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validateCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "validate [config.yaml...]",
|
||||
Short: "Validate agent config files",
|
||||
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)
|
||||
}
|
||||
}
|
||||
if !allOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Command launcher starts one or more agents from their config files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/enmanuel/agents/agents"
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPaths []string
|
||||
var 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
|
||||
}
|
||||
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
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
logger.Error("failed to load config", "path", path, "err", err)
|
||||
continue
|
||||
}
|
||||
if !cfg.Agent.Enabled {
|
||||
logger.Info("agent disabled, skipping", "id", cfg.Agent.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Load agent-specific rules (extend here with your own rule builders)
|
||||
rules := loadRulesForAgent(cfg)
|
||||
|
||||
agent, err := agents.New(cfg, rules, logger.With("agent", cfg.Agent.ID))
|
||||
if err != nil {
|
||||
logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Global Matrix configuration
|
||||
# Agent-specific overrides go in agents/<name>/config.yaml
|
||||
|
||||
homeserver: "${MATRIX_HOMESERVER}"
|
||||
server_name: "${MATRIX_SERVER_NAME}"
|
||||
|
||||
# Shared rooms — reference these IDs in per-agent configs
|
||||
rooms:
|
||||
devops: "${MATRIX_ROOM_DEVOPS}"
|
||||
alerts: "${MATRIX_ROOM_ALERTS}"
|
||||
logs: "${MATRIX_ROOM_LOGS}"
|
||||
admin: "${MATRIX_ROOM_ADMIN}"
|
||||
audit: "${MATRIX_ROOM_AUDIT}"
|
||||
agents_internal: "${MATRIX_ROOM_AGENTS_INTERNAL}"
|
||||
@@ -0,0 +1,28 @@
|
||||
# Global SSH server inventory
|
||||
# Referenced by target name in agent configs
|
||||
|
||||
defaults:
|
||||
user: deploy
|
||||
port: 22
|
||||
key_file_env: SSH_PRIVATE_KEY_PATH
|
||||
timeout: 10s
|
||||
keepalive_interval: 15s
|
||||
|
||||
targets:
|
||||
production:
|
||||
hosts:
|
||||
- "${PROD_HOST_1}"
|
||||
- "${PROD_HOST_2}"
|
||||
user: deploy
|
||||
jump_host: "${BASTION_HOST}"
|
||||
|
||||
staging:
|
||||
hosts:
|
||||
- "${STAGING_HOST}"
|
||||
user: deploy
|
||||
|
||||
monitoring:
|
||||
hosts:
|
||||
- "${MONITORING_HOST}"
|
||||
user: monitor
|
||||
key_file_env: SSH_MONITOR_KEY_PATH
|
||||
@@ -0,0 +1,39 @@
|
||||
module github.com/enmanuel/agents
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.5
|
||||
|
||||
require (
|
||||
github.com/mark3labs/mcp-go v0.44.1
|
||||
github.com/sashabaranov/go-openai v1.36.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
golang.org/x/crypto v0.31.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.21.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/rs/zerolog v1.33.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.mau.fi/util v0.8.1 // indirect
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mark3labs/mcp-go v0.44.1 h1:2PKppYlT9X2fXnE8SNYQLAX4hNjfPB0oNLqQVcN6mE8=
|
||||
github.com/mark3labs/mcp-go v0.44.1/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g=
|
||||
github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.mau.fi/util v0.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo=
|
||||
go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.21.1 h1:Z+e448jtlY977iC1kokNJTH5kg2WmDpcQCqn+v9oZOA=
|
||||
maunium.net/go/mautrix v0.21.1/go.mod h1:7F/S6XAdyc/6DW+Q7xyFXRSPb6IjfqMb1OMepQ8C8OE=
|
||||
@@ -0,0 +1,47 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Load reads and parses an agent config file from the given path.
|
||||
func Load(path string) (*AgentConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Expand environment variables in the raw YAML bytes.
|
||||
expanded := os.ExpandEnv(string(data))
|
||||
|
||||
var cfg AgentConfig
|
||||
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := validate(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid config %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// validate applies basic sanity checks.
|
||||
func validate(cfg *AgentConfig) error {
|
||||
if cfg.Agent.ID == "" {
|
||||
return fmt.Errorf("agent.id is required")
|
||||
}
|
||||
if cfg.Matrix.Homeserver == "" {
|
||||
return fmt.Errorf("matrix.homeserver is required")
|
||||
}
|
||||
if cfg.Matrix.UserID == "" {
|
||||
return fmt.Errorf("matrix.user_id is required")
|
||||
}
|
||||
if cfg.LLM.Primary.Provider == "" {
|
||||
return fmt.Errorf("llm.primary.provider is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// Package config provides the configuration schema and loader for agents.
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// AgentConfig is the root configuration for a single agent.
|
||||
type AgentConfig struct {
|
||||
Agent AgentMeta `yaml:"agent"`
|
||||
Personality PersonalityCfg `yaml:"personality"`
|
||||
LLM LLMCfg `yaml:"llm"`
|
||||
Tools ToolsCfg `yaml:"tools"`
|
||||
Matrix MatrixCfg `yaml:"matrix"`
|
||||
Agents AgentsCfg `yaml:"agents"`
|
||||
SSH SSHCfg `yaml:"ssh"`
|
||||
Security SecurityCfg `yaml:"security"`
|
||||
Schedules []ScheduleCfg `yaml:"schedules"`
|
||||
Observability ObservabilityCfg `yaml:"observability"`
|
||||
Resilience ResilienceCfg `yaml:"resilience"`
|
||||
Storage StorageCfg `yaml:"storage"`
|
||||
}
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────
|
||||
|
||||
type AgentMeta struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Description string `yaml:"description"`
|
||||
Tags []string `yaml:"tags"`
|
||||
}
|
||||
|
||||
// ── Personality ───────────────────────────────────────────────────────────
|
||||
|
||||
type PersonalityCfg struct {
|
||||
Tone string `yaml:"tone"`
|
||||
Verbosity string `yaml:"verbosity"`
|
||||
Language string `yaml:"language"`
|
||||
LanguagesSupported []string `yaml:"languages_supported"`
|
||||
EmojiStyle string `yaml:"emoji_style"`
|
||||
Prefix string `yaml:"prefix"`
|
||||
ErrorStyle string `yaml:"error_style"`
|
||||
Templates TemplatesCfg `yaml:"templates"`
|
||||
Behavior BehaviorCfg `yaml:"behavior"`
|
||||
}
|
||||
|
||||
type TemplatesCfg struct {
|
||||
Greeting string `yaml:"greeting"`
|
||||
UnknownCommand string `yaml:"unknown_command"`
|
||||
PermissionDenied string `yaml:"permission_denied"`
|
||||
Error string `yaml:"error"`
|
||||
Success string `yaml:"success"`
|
||||
Busy string `yaml:"busy"`
|
||||
}
|
||||
|
||||
type BehaviorCfg struct {
|
||||
Proactive bool `yaml:"proactive"`
|
||||
AskConfirmation bool `yaml:"ask_confirmation"`
|
||||
ShowReasoning bool `yaml:"show_reasoning"`
|
||||
ThreadReplies bool `yaml:"thread_replies"`
|
||||
TypingIndicator bool `yaml:"typing_indicator"`
|
||||
AcknowledgeReceipt bool `yaml:"acknowledge_receipt"`
|
||||
}
|
||||
|
||||
// ── LLM ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type LLMCfg struct {
|
||||
Primary LLMProviderCfg `yaml:"primary"`
|
||||
Fallback LLMProviderCfg `yaml:"fallback"`
|
||||
Reasoning LLMReasoningCfg `yaml:"reasoning"`
|
||||
ToolUse LLMToolUseCfg `yaml:"tool_use"`
|
||||
RateLimit LLMRateLimitCfg `yaml:"rate_limit"`
|
||||
}
|
||||
|
||||
type LLMProviderCfg struct {
|
||||
Provider string `yaml:"provider"`
|
||||
Model string `yaml:"model"`
|
||||
APIKeyEnv string `yaml:"api_key_env"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
MaxTokens int `yaml:"max_tokens"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
}
|
||||
|
||||
type LLMReasoningCfg struct {
|
||||
SystemPromptFile string `yaml:"system_prompt_file"`
|
||||
ContextWindow int `yaml:"context_window"`
|
||||
MemoryMessages int `yaml:"memory_messages"`
|
||||
}
|
||||
|
||||
type LLMToolUseCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
MaxIterations int `yaml:"max_iterations"`
|
||||
ParallelCalls bool `yaml:"parallel_calls"`
|
||||
}
|
||||
|
||||
type LLMRateLimitCfg struct {
|
||||
RequestsPerMinute int `yaml:"requests_per_minute"`
|
||||
TokensPerMinute int `yaml:"tokens_per_minute"`
|
||||
ConcurrentRequests int `yaml:"concurrent_requests"`
|
||||
}
|
||||
|
||||
// ── Tools ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type ToolsCfg struct {
|
||||
SSH SSHToolCfg `yaml:"ssh"`
|
||||
HTTP HTTPToolCfg `yaml:"http"`
|
||||
Scripts ScriptsCfg `yaml:"scripts"`
|
||||
FileOps FileOpsCfg `yaml:"file_ops"`
|
||||
MCP MCPToolCfg `yaml:"mcp"`
|
||||
}
|
||||
|
||||
type SSHToolCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
AllowedTargets []string `yaml:"allowed_targets"`
|
||||
ForbiddenCommands []string `yaml:"forbidden_commands"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
MaxConcurrent int `yaml:"max_concurrent"`
|
||||
RequireConfirmation []string `yaml:"require_confirmation"`
|
||||
}
|
||||
|
||||
type HTTPToolCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
AllowedDomains []string `yaml:"allowed_domains"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
MaxRetries int `yaml:"max_retries"`
|
||||
}
|
||||
|
||||
type ScriptsCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
ScriptsDir string `yaml:"scripts_dir"`
|
||||
Allowed []string `yaml:"allowed"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
Sandbox bool `yaml:"sandbox"`
|
||||
}
|
||||
|
||||
type FileOpsCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
AllowedPaths []string `yaml:"allowed_paths"`
|
||||
ReadOnly bool `yaml:"read_only"`
|
||||
}
|
||||
|
||||
type MCPToolCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Servers []MCPServerCfg `yaml:"servers"`
|
||||
Expose MCPExposeCfg `yaml:"expose"`
|
||||
}
|
||||
|
||||
type MCPServerCfg struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
Tools []string `yaml:"tools"`
|
||||
}
|
||||
|
||||
type MCPExposeCfg struct {
|
||||
Port int `yaml:"port"`
|
||||
Tools []string `yaml:"tools"`
|
||||
}
|
||||
|
||||
// ── Matrix ────────────────────────────────────────────────────────────────
|
||||
|
||||
type MatrixCfg struct {
|
||||
Homeserver string `yaml:"homeserver"`
|
||||
UserID string `yaml:"user_id"`
|
||||
AccessTokenEnv string `yaml:"access_token_env"`
|
||||
DeviceID string `yaml:"device_id"`
|
||||
Encryption EncryptionCfg `yaml:"encryption"`
|
||||
Rooms RoomsCfg `yaml:"rooms"`
|
||||
Filters FiltersCfg `yaml:"filters"`
|
||||
}
|
||||
|
||||
type EncryptionCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
StorePath string `yaml:"store_path"`
|
||||
TrustMode string `yaml:"trust_mode"` // tofu | cross-signing | manual
|
||||
}
|
||||
|
||||
type RoomsCfg struct {
|
||||
Listen []string `yaml:"listen"`
|
||||
Respond []string `yaml:"respond"`
|
||||
Admin []string `yaml:"admin"`
|
||||
}
|
||||
|
||||
type FiltersCfg struct {
|
||||
CommandPrefix string `yaml:"command_prefix"`
|
||||
MentionRespond bool `yaml:"mention_respond"`
|
||||
DMRespond bool `yaml:"dm_respond"`
|
||||
IgnoreBots bool `yaml:"ignore_bots"`
|
||||
IgnoreUsers []string `yaml:"ignore_users"`
|
||||
MinPowerLevel int `yaml:"min_power_level"`
|
||||
}
|
||||
|
||||
// ── Inter-agent ───────────────────────────────────────────────────────────
|
||||
|
||||
type AgentsCfg struct {
|
||||
Peers []PeerCfg `yaml:"peers"`
|
||||
Delegation DelegationCfg `yaml:"delegation"`
|
||||
Protocol ProtocolCfg `yaml:"protocol"`
|
||||
}
|
||||
|
||||
type PeerCfg struct {
|
||||
ID string `yaml:"id"`
|
||||
Capabilities []string `yaml:"capabilities"`
|
||||
Room string `yaml:"room"`
|
||||
}
|
||||
|
||||
type DelegationCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
CanDelegateTo []string `yaml:"can_delegate_to"`
|
||||
CanReceiveFrom []string `yaml:"can_receive_from"`
|
||||
MaxDepth int `yaml:"max_delegation_depth"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
}
|
||||
|
||||
type ProtocolCfg struct {
|
||||
Format string `yaml:"format"` // json | protobuf | msgpack
|
||||
Channel string `yaml:"channel"` // matrix | grpc | channel
|
||||
HeartbeatInterval time.Duration `yaml:"heartbeat_interval"`
|
||||
}
|
||||
|
||||
// ── SSH Inventory ─────────────────────────────────────────────────────────
|
||||
|
||||
type SSHCfg struct {
|
||||
Defaults SSHDefaultsCfg `yaml:"defaults"`
|
||||
Targets map[string]SSHTargetCfg `yaml:"targets"`
|
||||
}
|
||||
|
||||
type SSHDefaultsCfg struct {
|
||||
User string `yaml:"user"`
|
||||
Port int `yaml:"port"`
|
||||
KeyFileEnv string `yaml:"key_file_env"`
|
||||
KnownHosts string `yaml:"known_hosts"`
|
||||
KeepaliveInterval time.Duration `yaml:"keepalive_interval"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
}
|
||||
|
||||
type SSHTargetCfg struct {
|
||||
Hosts []string `yaml:"hosts"`
|
||||
User string `yaml:"user"`
|
||||
Port int `yaml:"port"`
|
||||
JumpHost string `yaml:"jump_host"`
|
||||
KeyFileEnv string `yaml:"key_file_env"`
|
||||
}
|
||||
|
||||
// ── Security ──────────────────────────────────────────────────────────────
|
||||
|
||||
type SecurityCfg struct {
|
||||
Roles map[string]RoleCfg `yaml:"roles"`
|
||||
Audit AuditCfg `yaml:"audit"`
|
||||
Secrets SecretsCfg `yaml:"secrets"`
|
||||
}
|
||||
|
||||
type RoleCfg struct {
|
||||
Users []string `yaml:"users"`
|
||||
Actions []string `yaml:"actions"`
|
||||
}
|
||||
|
||||
type AuditCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
LogFile string `yaml:"log_file"`
|
||||
LogToRoom string `yaml:"log_to_room"`
|
||||
Include []string `yaml:"include"`
|
||||
}
|
||||
|
||||
type SecretsCfg struct {
|
||||
Provider string `yaml:"provider"` // env | vault | sops
|
||||
}
|
||||
|
||||
// ── Scheduling ────────────────────────────────────────────────────────────
|
||||
|
||||
type ScheduleCfg struct {
|
||||
Name string `yaml:"name"`
|
||||
Cron string `yaml:"cron"`
|
||||
Action ScheduledAction `yaml:"action"`
|
||||
OnFailure FailureAction `yaml:"on_failure"`
|
||||
OutputRoom string `yaml:"output_room"`
|
||||
}
|
||||
|
||||
type ScheduledAction struct {
|
||||
Kind string `yaml:"kind"`
|
||||
Target string `yaml:"target"`
|
||||
Command string `yaml:"command"`
|
||||
Script string `yaml:"script"`
|
||||
}
|
||||
|
||||
type FailureAction struct {
|
||||
NotifyRoom string `yaml:"notify_room"`
|
||||
EscalateTo string `yaml:"escalate_to"`
|
||||
}
|
||||
|
||||
// ── Observability ─────────────────────────────────────────────────────────
|
||||
|
||||
type ObservabilityCfg struct {
|
||||
Logging LoggingCfg `yaml:"logging"`
|
||||
Metrics MetricsCfg `yaml:"metrics"`
|
||||
Health HealthCfg `yaml:"health"`
|
||||
Tracing TracingCfg `yaml:"tracing"`
|
||||
}
|
||||
|
||||
type LoggingCfg struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"` // json | text
|
||||
Output string `yaml:"output"` // stdout | file
|
||||
File string `yaml:"file"`
|
||||
}
|
||||
|
||||
type MetricsCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Port int `yaml:"port"`
|
||||
Path string `yaml:"path"`
|
||||
Export string `yaml:"export"` // prometheus
|
||||
}
|
||||
|
||||
type HealthCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Port int `yaml:"port"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
type TracingCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Provider string `yaml:"provider"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
}
|
||||
|
||||
// ── Resilience ────────────────────────────────────────────────────────────
|
||||
|
||||
type ResilienceCfg struct {
|
||||
CircuitBreaker CircuitBreakerCfg `yaml:"circuit_breaker"`
|
||||
Retry RetryCfg `yaml:"retry"`
|
||||
Shutdown ShutdownCfg `yaml:"shutdown"`
|
||||
Queue QueueCfg `yaml:"queue"`
|
||||
}
|
||||
|
||||
type CircuitBreakerCfg struct {
|
||||
FailureThreshold int `yaml:"failure_threshold"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
HalfOpenMax int `yaml:"half_open_max"`
|
||||
}
|
||||
|
||||
type RetryCfg struct {
|
||||
MaxAttempts int `yaml:"max_attempts"`
|
||||
Backoff string `yaml:"backoff"` // fixed | exponential
|
||||
InitialDelay time.Duration `yaml:"initial_delay"`
|
||||
MaxDelay time.Duration `yaml:"max_delay"`
|
||||
}
|
||||
|
||||
type ShutdownCfg struct {
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
DrainMessages bool `yaml:"drain_messages"`
|
||||
SaveState bool `yaml:"save_state"`
|
||||
StateFile string `yaml:"state_file"`
|
||||
}
|
||||
|
||||
type QueueCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
MaxSize int `yaml:"max_size"`
|
||||
PriorityUsers []string `yaml:"priority_users"`
|
||||
}
|
||||
|
||||
// ── Storage ───────────────────────────────────────────────────────────────
|
||||
|
||||
type StorageCfg struct {
|
||||
State StateStorageCfg `yaml:"state"`
|
||||
Cache CacheStorageCfg `yaml:"cache"`
|
||||
History HistoryStorageCfg `yaml:"history"`
|
||||
}
|
||||
|
||||
type StateStorageCfg struct {
|
||||
Backend string `yaml:"backend"` // sqlite | redis | file
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
type CacheStorageCfg struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Backend string `yaml:"backend"` // memory | redis
|
||||
TTL time.Duration `yaml:"ttl"`
|
||||
MaxEntries int `yaml:"max_entries"`
|
||||
}
|
||||
|
||||
type HistoryStorageCfg struct {
|
||||
Backend string `yaml:"backend"`
|
||||
Path string `yaml:"path"`
|
||||
Retention time.Duration `yaml:"retention"`
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package decision
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rule maps a condition to a set of actions.
|
||||
type Rule struct {
|
||||
Name string
|
||||
Match MatchFunc
|
||||
Actions []Action
|
||||
}
|
||||
|
||||
// MatchFunc is a pure predicate over a MessageContext.
|
||||
type MatchFunc func(ctx MessageContext) bool
|
||||
|
||||
// Evaluate runs all rules against the context and returns the matching actions. Pure.
|
||||
func Evaluate(ctx MessageContext, rules []Rule) []Action {
|
||||
var actions []Action
|
||||
for _, rule := range rules {
|
||||
if rule.Match(ctx) {
|
||||
actions = append(actions, rule.Actions...)
|
||||
}
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// MatchCommand returns a MatchFunc that matches when the command equals cmd.
|
||||
func MatchCommand(cmd string) MatchFunc {
|
||||
return func(ctx MessageContext) bool {
|
||||
return strings.EqualFold(ctx.Command, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// MatchPrefix returns a MatchFunc that matches when content starts with prefix.
|
||||
func MatchPrefix(prefix string) MatchFunc {
|
||||
return func(ctx MessageContext) bool {
|
||||
return strings.HasPrefix(ctx.Content, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// MatchAny returns a MatchFunc that matches any message.
|
||||
func MatchAny() MatchFunc {
|
||||
return func(_ MessageContext) bool { return true }
|
||||
}
|
||||
|
||||
// MatchMinPowerLevel returns a MatchFunc that requires a minimum Matrix power level.
|
||||
func MatchMinPowerLevel(level int) MatchFunc {
|
||||
return func(ctx MessageContext) bool {
|
||||
return ctx.PowerLevel >= level
|
||||
}
|
||||
}
|
||||
|
||||
// And composes multiple MatchFuncs with logical AND.
|
||||
func And(fns ...MatchFunc) MatchFunc {
|
||||
return func(ctx MessageContext) bool {
|
||||
for _, fn := range fns {
|
||||
if !fn(ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Or composes multiple MatchFuncs with logical OR.
|
||||
func Or(fns ...MatchFunc) MatchFunc {
|
||||
return func(ctx MessageContext) bool {
|
||||
for _, fn := range fns {
|
||||
if fn(ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package decision implements the pure decision engine.
|
||||
// Input: MessageContext. Output: []Action. Zero side effects.
|
||||
package decision
|
||||
|
||||
import "github.com/enmanuel/agents/pkg/tools"
|
||||
|
||||
// MessageContext holds all the information about an incoming message.
|
||||
type MessageContext struct {
|
||||
SenderID string
|
||||
SenderName string
|
||||
RoomID string
|
||||
Content string
|
||||
Command string // parsed command name, e.g. "deploy"
|
||||
Args []string // parsed arguments
|
||||
PowerLevel int
|
||||
IsDirectMsg bool
|
||||
IsMention bool
|
||||
ThreadID string
|
||||
}
|
||||
|
||||
// ActionKind is the type of action to perform.
|
||||
type ActionKind string
|
||||
|
||||
const (
|
||||
ActionKindReply ActionKind = "reply"
|
||||
ActionKindSSH ActionKind = "ssh"
|
||||
ActionKindHTTP ActionKind = "http"
|
||||
ActionKindScript ActionKind = "script"
|
||||
ActionKindFileOps ActionKind = "file_ops"
|
||||
ActionKindMCP ActionKind = "mcp"
|
||||
ActionKindLLM ActionKind = "llm"
|
||||
ActionKindDelegate ActionKind = "delegate"
|
||||
)
|
||||
|
||||
// Action is a pure description of what the shell should do.
|
||||
// It is a tagged union — only the field matching Kind is set.
|
||||
type Action struct {
|
||||
Kind ActionKind
|
||||
Reply *ReplyAction
|
||||
SSH *tools.SSHCommandSpec
|
||||
HTTP *tools.HTTPCallSpec
|
||||
Script *tools.ScriptSpec
|
||||
FileOps *tools.FileOpsSpec
|
||||
MCP *tools.MCPCallSpec
|
||||
LLM *LLMAction
|
||||
Delegate *DelegateAction
|
||||
}
|
||||
|
||||
type ReplyAction struct {
|
||||
Content string
|
||||
ThreadID string // empty = new thread
|
||||
Reaction string // optional Matrix reaction
|
||||
}
|
||||
|
||||
type LLMAction struct {
|
||||
ContextKey string // key to look up conversation history
|
||||
ExtraTools []string // additional tool names to make available
|
||||
}
|
||||
|
||||
type DelegateAction struct {
|
||||
TargetAgentID string
|
||||
Task string
|
||||
Context map[string]string
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package llm
|
||||
|
||||
import "strings"
|
||||
|
||||
// Route maps a model string to its provider. Pure function.
|
||||
func Route(model string) ProviderID {
|
||||
switch {
|
||||
case strings.HasPrefix(model, "claude"):
|
||||
return ProviderAnthropic
|
||||
case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"):
|
||||
return ProviderOpenAI
|
||||
case strings.HasPrefix(model, "ollama/"):
|
||||
return ProviderOllama
|
||||
default:
|
||||
return ProviderOpenAI
|
||||
}
|
||||
}
|
||||
|
||||
// ModelName strips the provider prefix from a model string.
|
||||
func ModelName(model string) string {
|
||||
if after, ok := strings.CutPrefix(model, "ollama/"); ok {
|
||||
return after
|
||||
}
|
||||
return model
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Package llm defines pure types for LLM provider communication.
|
||||
// No side effects — only data and transformations.
|
||||
package llm
|
||||
|
||||
import "context"
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleSystem Role = "system"
|
||||
RoleUser Role = "user"
|
||||
RoleAssistant Role = "assistant"
|
||||
RoleTool Role = "tool"
|
||||
)
|
||||
|
||||
type ProviderID string
|
||||
|
||||
const (
|
||||
ProviderAnthropic ProviderID = "anthropic"
|
||||
ProviderOpenAI ProviderID = "openai"
|
||||
ProviderOllama ProviderID = "ollama"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role Role
|
||||
Content string
|
||||
ToolCallID string
|
||||
ToolCalls []ToolCall
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string
|
||||
Name string
|
||||
Arguments string // JSON-encoded
|
||||
}
|
||||
|
||||
type ToolSpec struct {
|
||||
Name string
|
||||
Description string
|
||||
InputSchema map[string]any
|
||||
}
|
||||
|
||||
type CompletionRequest struct {
|
||||
Model string
|
||||
Messages []Message
|
||||
Tools []ToolSpec
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
Stream bool
|
||||
SystemPrompt string
|
||||
}
|
||||
|
||||
type TokenUsage struct {
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
TotalTokens int
|
||||
}
|
||||
|
||||
type CompletionResponse struct {
|
||||
Content string
|
||||
ToolCalls []ToolCall
|
||||
Usage TokenUsage
|
||||
FinishReason string
|
||||
}
|
||||
|
||||
// CompleteFunc is the single contract for LLM providers.
|
||||
// Implementations live in shell/llm/.
|
||||
type CompleteFunc func(ctx context.Context, req CompletionRequest) (CompletionResponse, error)
|
||||
@@ -0,0 +1,28 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// Render executes a Go template string with the given data. Pure.
|
||||
func Render(tmpl string, data any) (string, error) {
|
||||
t, err := template.New("").Parse(tmpl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// MustRender is like Render but panics on error. Use only in tests.
|
||||
func MustRender(tmpl string, data any) string {
|
||||
s, err := Render(tmpl, data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package message provides pure parsing and formatting for Matrix messages.
|
||||
package message
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
)
|
||||
|
||||
// ParseOptions configures how messages are parsed.
|
||||
type ParseOptions struct {
|
||||
CommandPrefix string // e.g. "!"
|
||||
BotUserID string // for mention detection
|
||||
}
|
||||
|
||||
// Parse converts a raw Matrix message body into a structured MessageContext. Pure.
|
||||
func Parse(body, senderID, roomID string, powerLevel int, isDM bool, opts ParseOptions) decision.MessageContext {
|
||||
ctx := decision.MessageContext{
|
||||
SenderID: senderID,
|
||||
RoomID: roomID,
|
||||
Content: body,
|
||||
PowerLevel: powerLevel,
|
||||
IsDirectMsg: isDM,
|
||||
}
|
||||
|
||||
// Detect mention
|
||||
if opts.BotUserID != "" && strings.Contains(body, opts.BotUserID) {
|
||||
ctx.IsMention = true
|
||||
body = strings.ReplaceAll(body, opts.BotUserID, "")
|
||||
body = strings.TrimSpace(body)
|
||||
}
|
||||
|
||||
// Parse command
|
||||
if opts.CommandPrefix != "" && strings.HasPrefix(body, opts.CommandPrefix) {
|
||||
parts := strings.Fields(strings.TrimPrefix(body, opts.CommandPrefix))
|
||||
if len(parts) > 0 {
|
||||
ctx.Command = strings.ToLower(parts[0])
|
||||
ctx.Args = parts[1:]
|
||||
}
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package personality defines pure types for agent personality and behavior.
|
||||
package personality
|
||||
|
||||
type Tone string
|
||||
|
||||
const (
|
||||
ToneDirect Tone = "direct"
|
||||
ToneFriendly Tone = "friendly"
|
||||
ToneFormal Tone = "formal"
|
||||
ToneCasual Tone = "casual"
|
||||
ToneTechnical Tone = "technical"
|
||||
)
|
||||
|
||||
type Verbosity string
|
||||
|
||||
const (
|
||||
VerbosityMinimal Verbosity = "minimal"
|
||||
VerbosityConcise Verbosity = "concise"
|
||||
VerbosityDetailed Verbosity = "detailed"
|
||||
VerbosityVerbose Verbosity = "verbose"
|
||||
)
|
||||
|
||||
type EmojiStyle string
|
||||
|
||||
const (
|
||||
EmojiNone EmojiStyle = "none"
|
||||
EmojiMinimal EmojiStyle = "minimal"
|
||||
EmojiModerate EmojiStyle = "moderate"
|
||||
EmojiHeavy EmojiStyle = "heavy"
|
||||
)
|
||||
|
||||
type ErrorStyle string
|
||||
|
||||
const (
|
||||
ErrorTerse ErrorStyle = "terse"
|
||||
ErrorHelpful ErrorStyle = "helpful"
|
||||
ErrorDetailed ErrorStyle = "detailed"
|
||||
)
|
||||
|
||||
type Templates struct {
|
||||
Greeting string
|
||||
UnknownCommand string
|
||||
PermissionDenied string
|
||||
Error string
|
||||
Success string
|
||||
Busy string
|
||||
}
|
||||
|
||||
type Behavior struct {
|
||||
Proactive bool
|
||||
AskConfirmation bool
|
||||
ShowReasoning bool
|
||||
ThreadReplies bool
|
||||
TypingIndicator bool
|
||||
AcknowledgeReceipt bool
|
||||
}
|
||||
|
||||
type Personality struct {
|
||||
Tone Tone
|
||||
Verbosity Verbosity
|
||||
Language string
|
||||
LanguagesSupported []string
|
||||
EmojiStyle EmojiStyle
|
||||
Prefix string
|
||||
ErrorStyle ErrorStyle
|
||||
Templates Templates
|
||||
Behavior Behavior
|
||||
}
|
||||
|
||||
// DefaultPersonality returns a sensible baseline.
|
||||
func DefaultPersonality() Personality {
|
||||
return Personality{
|
||||
Tone: ToneFriendly,
|
||||
Verbosity: VerbosityConcise,
|
||||
Language: "en",
|
||||
EmojiStyle: EmojiMinimal,
|
||||
ErrorStyle: ErrorHelpful,
|
||||
Templates: Templates{
|
||||
Greeting: "Ready. What do you need?",
|
||||
UnknownCommand: "Unknown command. Use `!help` for available commands.",
|
||||
PermissionDenied: "You don't have permission for that.",
|
||||
Error: "Something failed: {{.Error}}",
|
||||
Success: "Done. {{.Summary}}",
|
||||
Busy: "I'm busy with another task. Wait or use `!queue`.",
|
||||
},
|
||||
Behavior: Behavior{
|
||||
AskConfirmation: true,
|
||||
ThreadReplies: true,
|
||||
TypingIndicator: true,
|
||||
AcknowledgeReceipt: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package tools defines pure, declarative tool specifications.
|
||||
// No execution happens here — only data describing what tools exist and their contracts.
|
||||
package tools
|
||||
|
||||
// ToolKind identifies the category of a tool.
|
||||
type ToolKind string
|
||||
|
||||
const (
|
||||
ToolKindSSH ToolKind = "ssh"
|
||||
ToolKindHTTP ToolKind = "http"
|
||||
ToolKindScript ToolKind = "script"
|
||||
ToolKindFileOps ToolKind = "file_ops"
|
||||
ToolKindMCP ToolKind = "mcp"
|
||||
)
|
||||
|
||||
// ToolSpec is a pure description of a tool — what it does and what it accepts.
|
||||
// The actual execution lives in shell/effects/.
|
||||
type ToolSpec struct {
|
||||
Name string
|
||||
Kind ToolKind
|
||||
Description string
|
||||
Parameters []ParameterSpec
|
||||
}
|
||||
|
||||
type ParameterSpec struct {
|
||||
Name string
|
||||
Type string
|
||||
Description string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// Registry is a map of available tools, keyed by name.
|
||||
type Registry map[string]ToolSpec
|
||||
|
||||
// Add returns a new Registry with the given spec added.
|
||||
func (r Registry) Add(spec ToolSpec) Registry {
|
||||
out := make(Registry, len(r)+1)
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
out[spec.Name] = spec
|
||||
return out
|
||||
}
|
||||
|
||||
// Get looks up a tool spec by name.
|
||||
func (r Registry) Get(name string) (ToolSpec, bool) {
|
||||
spec, ok := r[name]
|
||||
return spec, ok
|
||||
}
|
||||
|
||||
// Names returns all registered tool names.
|
||||
func (r Registry) Names() []string {
|
||||
names := make([]string, 0, len(r))
|
||||
for k := range r {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package tools
|
||||
|
||||
// SSHCommandSpec describes an SSH command to execute. Pure data — no execution.
|
||||
type SSHCommandSpec struct {
|
||||
Target string // references a named target in ssh config
|
||||
Command string
|
||||
Timeout string
|
||||
}
|
||||
|
||||
// HTTPCallSpec describes an HTTP call to make. Pure data.
|
||||
type HTTPCallSpec struct {
|
||||
Method string
|
||||
URL string
|
||||
Headers map[string]string
|
||||
Body string
|
||||
Timeout string
|
||||
}
|
||||
|
||||
// ScriptSpec describes a script to run. Pure data.
|
||||
type ScriptSpec struct {
|
||||
Name string
|
||||
Args []string
|
||||
Timeout string
|
||||
}
|
||||
|
||||
// FileOpsSpec describes a file operation. Pure data.
|
||||
type FileOpsSpec struct {
|
||||
Op string // read | write | list | delete
|
||||
Path string
|
||||
}
|
||||
|
||||
// MCPCallSpec describes a call to an MCP server. Pure data.
|
||||
type MCPCallSpec struct {
|
||||
ServerName string
|
||||
ToolName string
|
||||
Arguments map[string]any
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package bus provides in-process agent-to-agent message passing.
|
||||
package bus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// AgentID identifies an agent.
|
||||
type AgentID string
|
||||
|
||||
// AgentMessage is a message between agents.
|
||||
type AgentMessage struct {
|
||||
From AgentID
|
||||
To AgentID
|
||||
Kind string
|
||||
Payload map[string]string
|
||||
}
|
||||
|
||||
// Bus manages channels for inter-agent communication.
|
||||
type Bus struct {
|
||||
mu sync.RWMutex
|
||||
channels map[AgentID]chan AgentMessage
|
||||
}
|
||||
|
||||
// New creates a new Bus.
|
||||
func New() *Bus {
|
||||
return &Bus{channels: make(map[AgentID]chan AgentMessage)}
|
||||
}
|
||||
|
||||
// Subscribe registers an agent and returns its receive channel.
|
||||
func (b *Bus) Subscribe(id AgentID) <-chan AgentMessage {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
ch := make(chan AgentMessage, 64)
|
||||
b.channels[id] = ch
|
||||
return ch
|
||||
}
|
||||
|
||||
// Send delivers a message to an agent's channel.
|
||||
func (b *Bus) Send(msg AgentMessage) error {
|
||||
b.mu.RLock()
|
||||
ch, ok := b.channels[msg.To]
|
||||
b.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("agent %q not registered on bus", msg.To)
|
||||
}
|
||||
select {
|
||||
case ch <- msg:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("agent %q message queue full", msg.To)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe removes an agent from the bus.
|
||||
func (b *Bus) Unsubscribe(id AgentID) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if ch, ok := b.channels[id]; ok {
|
||||
close(ch)
|
||||
delete(b.channels, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package effects interprets pure []decision.Action values into real side effects.
|
||||
package effects
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
"github.com/enmanuel/agents/shell/ssh"
|
||||
)
|
||||
|
||||
// Result holds the outcome of executing a single action.
|
||||
type Result struct {
|
||||
Action decision.Action
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
// MatrixSender is satisfied by shell/matrix.Client.
|
||||
type MatrixSender interface {
|
||||
SendText(ctx context.Context, roomID, text string) error
|
||||
SendTyping(ctx context.Context, roomID string, typing bool) error
|
||||
}
|
||||
|
||||
// Runner interprets actions and executes them.
|
||||
type Runner struct {
|
||||
matrix MatrixSender
|
||||
ssh *ssh.Executor
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewRunner creates a Runner with the provided dependencies.
|
||||
func NewRunner(matrix MatrixSender, ssh *ssh.Executor, logger *slog.Logger) *Runner {
|
||||
return &Runner{matrix: matrix, ssh: ssh, logger: logger}
|
||||
}
|
||||
|
||||
// Execute runs each action sequentially and returns results.
|
||||
func (r *Runner) Execute(ctx context.Context, roomID string, actions []decision.Action) []Result {
|
||||
results := make([]Result, 0, len(actions))
|
||||
for _, a := range actions {
|
||||
res := r.executeOne(ctx, roomID, a)
|
||||
results = append(results, res)
|
||||
if res.Err != nil {
|
||||
r.logger.Error("action failed", "kind", a.Kind, "err", res.Err)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (r *Runner) executeOne(ctx context.Context, roomID string, a decision.Action) Result {
|
||||
switch a.Kind {
|
||||
case decision.ActionKindReply:
|
||||
if a.Reply == nil {
|
||||
return Result{Action: a, Err: fmt.Errorf("nil reply action")}
|
||||
}
|
||||
target := roomID
|
||||
if a.Reply.ThreadID != "" {
|
||||
target = a.Reply.ThreadID
|
||||
}
|
||||
err := r.matrix.SendText(ctx, target, a.Reply.Content)
|
||||
return Result{Action: a, Output: a.Reply.Content, Err: err}
|
||||
|
||||
case decision.ActionKindSSH:
|
||||
if a.SSH == nil {
|
||||
return Result{Action: a, Err: fmt.Errorf("nil ssh action")}
|
||||
}
|
||||
res := r.ssh.Execute(ctx, *a.SSH)
|
||||
output := res.Stdout
|
||||
if res.Stderr != "" {
|
||||
output += "\nstderr: " + res.Stderr
|
||||
}
|
||||
return Result{Action: a, Output: output, Err: res.Err}
|
||||
|
||||
default:
|
||||
return Result{Action: a, Err: fmt.Errorf("unhandled action kind: %s", a.Kind)}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package llm contains impure LLM provider implementations.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
coretypes "github.com/enmanuel/agents/pkg/llm"
|
||||
)
|
||||
|
||||
const anthropicAPIBase = "https://api.anthropic.com/v1"
|
||||
const anthropicVersion = "2023-06-01"
|
||||
|
||||
// NewAnthropicComplete returns a CompleteFunc backed by the Anthropic API.
|
||||
func NewAnthropicComplete(apiKeyEnv, baseURL string) coretypes.CompleteFunc {
|
||||
if baseURL == "" {
|
||||
baseURL = anthropicAPIBase
|
||||
}
|
||||
|
||||
return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) {
|
||||
apiKey := os.Getenv(apiKeyEnv)
|
||||
if apiKey == "" {
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("env var %s is not set", apiKeyEnv)
|
||||
}
|
||||
|
||||
body := toAnthropicRequest(req)
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/messages", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return coretypes.CompletionResponse{}, err
|
||||
}
|
||||
httpReq.Header.Set("x-api-key", apiKey)
|
||||
httpReq.Header.Set("anthropic-version", anthropicVersion)
|
||||
httpReq.Header.Set("content-type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("anthropic request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("anthropic error %d: %s", resp.StatusCode, respBytes)
|
||||
}
|
||||
|
||||
return fromAnthropicResponse(respBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// ── private conversion helpers ────────────────────────────────────────────
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type anthropicTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"input_schema"`
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
func toAnthropicRequest(req coretypes.CompletionRequest) anthropicRequest {
|
||||
msgs := make([]anthropicMessage, 0, len(req.Messages))
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == coretypes.RoleSystem {
|
||||
continue // handled as top-level system param
|
||||
}
|
||||
msgs = append(msgs, anthropicMessage{
|
||||
Role: string(m.Role),
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
|
||||
tools := make([]anthropicTool, len(req.Tools))
|
||||
for i, t := range req.Tools {
|
||||
tools[i] = anthropicTool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.InputSchema,
|
||||
}
|
||||
}
|
||||
|
||||
return anthropicRequest{
|
||||
Model: req.Model,
|
||||
MaxTokens: req.MaxTokens,
|
||||
System: req.SystemPrompt,
|
||||
Messages: msgs,
|
||||
Tools: tools,
|
||||
}
|
||||
}
|
||||
|
||||
func fromAnthropicResponse(raw []byte) (coretypes.CompletionResponse, error) {
|
||||
var ar anthropicResponse
|
||||
if err := json.Unmarshal(raw, &ar); err != nil {
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
var content string
|
||||
for _, c := range ar.Content {
|
||||
if c.Type == "text" {
|
||||
content += c.Text
|
||||
}
|
||||
}
|
||||
return coretypes.CompletionResponse{
|
||||
Content: content,
|
||||
FinishReason: ar.StopReason,
|
||||
Usage: coretypes.TokenUsage{
|
||||
InputTokens: ar.Usage.InputTokens,
|
||||
OutputTokens: ar.Usage.OutputTokens,
|
||||
TotalTokens: ar.Usage.InputTokens + ar.Usage.OutputTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
coretypes "github.com/enmanuel/agents/pkg/llm"
|
||||
)
|
||||
|
||||
// FromConfig builds a CompleteFunc from an LLMProviderCfg.
|
||||
func FromConfig(cfg config.LLMProviderCfg) (coretypes.CompleteFunc, error) {
|
||||
switch cfg.Provider {
|
||||
case "anthropic":
|
||||
return NewAnthropicComplete(cfg.APIKeyEnv, cfg.BaseURL), nil
|
||||
case "openai":
|
||||
return NewOpenAIComplete(cfg.APIKeyEnv, cfg.BaseURL), nil
|
||||
case "ollama":
|
||||
base := cfg.BaseURL
|
||||
if base == "" {
|
||||
base = "http://localhost:11434/v1"
|
||||
}
|
||||
return NewOpenAIComplete("OLLAMA_API_KEY", base), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown LLM provider: %s", cfg.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// WithFallback wraps primary with a fallback CompleteFunc.
|
||||
// If primary returns an error, fallback is tried.
|
||||
func WithFallback(primary, fallback coretypes.CompleteFunc) coretypes.CompleteFunc {
|
||||
return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) {
|
||||
resp, err := primary(ctx, req)
|
||||
if err != nil {
|
||||
return fallback(ctx, req)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
|
||||
coretypes "github.com/enmanuel/agents/pkg/llm"
|
||||
)
|
||||
|
||||
// NewOpenAIComplete returns a CompleteFunc backed by the OpenAI-compatible API.
|
||||
// Works with OpenAI, Ollama, vLLM, LMStudio — just change baseURL.
|
||||
func NewOpenAIComplete(apiKeyEnv, baseURL string) coretypes.CompleteFunc {
|
||||
return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) {
|
||||
apiKey := os.Getenv(apiKeyEnv)
|
||||
if apiKey == "" {
|
||||
apiKey = "ollama" // Ollama doesn't require a real key
|
||||
}
|
||||
|
||||
cfg := openai.DefaultConfig(apiKey)
|
||||
if baseURL != "" {
|
||||
cfg.BaseURL = baseURL
|
||||
}
|
||||
client := openai.NewClientWithConfig(cfg)
|
||||
|
||||
msgs := make([]openai.ChatCompletionMessage, 0, len(req.Messages)+1)
|
||||
if req.SystemPrompt != "" {
|
||||
msgs = append(msgs, openai.ChatCompletionMessage{
|
||||
Role: openai.ChatMessageRoleSystem,
|
||||
Content: req.SystemPrompt,
|
||||
})
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
role := openai.ChatMessageRoleUser
|
||||
switch m.Role {
|
||||
case coretypes.RoleAssistant:
|
||||
role = openai.ChatMessageRoleAssistant
|
||||
case coretypes.RoleSystem:
|
||||
role = openai.ChatMessageRoleSystem
|
||||
case coretypes.RoleTool:
|
||||
role = openai.ChatMessageRoleTool
|
||||
}
|
||||
msgs = append(msgs, openai.ChatCompletionMessage{
|
||||
Role: role,
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
|
||||
openReq := openai.ChatCompletionRequest{
|
||||
Model: req.Model,
|
||||
Messages: msgs,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: float32(req.Temperature),
|
||||
}
|
||||
|
||||
resp, err := client.CreateChatCompletion(ctx, openReq)
|
||||
if err != nil {
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("openai completion: %w", err)
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("openai: empty choices")
|
||||
}
|
||||
|
||||
return coretypes.CompletionResponse{
|
||||
Content: resp.Choices[0].Message.Content,
|
||||
FinishReason: string(resp.Choices[0].FinishReason),
|
||||
Usage: coretypes.TokenUsage{
|
||||
InputTokens: resp.Usage.PromptTokens,
|
||||
OutputTokens: resp.Usage.CompletionTokens,
|
||||
TotalTokens: resp.Usage.TotalTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package matrix wraps mautrix-go for agent use.
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
)
|
||||
|
||||
// Client wraps a mautrix client with agent-relevant helpers.
|
||||
type Client struct {
|
||||
raw *mautrix.Client
|
||||
cfg config.MatrixCfg
|
||||
}
|
||||
|
||||
// New creates and authenticates a Matrix client from config.
|
||||
// The access token is read from the env var specified in cfg.AccessTokenEnv.
|
||||
func New(cfg config.MatrixCfg) (*Client, error) {
|
||||
token := os.Getenv(cfg.AccessTokenEnv)
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("env var %s is not set", cfg.AccessTokenEnv)
|
||||
}
|
||||
|
||||
raw, err := mautrix.NewClient(cfg.Homeserver, id.UserID(cfg.UserID), token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create matrix client: %w", err)
|
||||
}
|
||||
|
||||
if cfg.DeviceID != "" {
|
||||
raw.DeviceID = id.DeviceID(cfg.DeviceID)
|
||||
}
|
||||
|
||||
return &Client{raw: raw, cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// SendText sends a plain-text message to a room.
|
||||
func (c *Client) SendText(ctx context.Context, roomID, text string) error {
|
||||
_, err := c.raw.SendText(ctx, id.RoomID(roomID), text)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendMarkdown sends a formatted (Markdown) message to a room.
|
||||
func (c *Client) SendMarkdown(ctx context.Context, roomID, markdown string) error {
|
||||
content := event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: markdown,
|
||||
Format: event.FormatHTML,
|
||||
FormattedBody: markdown, // mautrix can render markdown -> HTML if needed
|
||||
}
|
||||
_, err := c.raw.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendReaction sends a reaction to an event.
|
||||
func (c *Client) SendReaction(ctx context.Context, roomID, eventID, reaction string) error {
|
||||
_, err := c.raw.SendReaction(ctx, id.RoomID(roomID), id.EventID(eventID), reaction)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendTyping sets the typing indicator in a room.
|
||||
func (c *Client) SendTyping(ctx context.Context, roomID string, typing bool) error {
|
||||
_, err := c.raw.UserTyping(ctx, id.RoomID(roomID), typing, 5000)
|
||||
return err
|
||||
}
|
||||
|
||||
// Raw returns the underlying mautrix.Client for advanced use.
|
||||
func (c *Client) Raw() *mautrix.Client {
|
||||
return c.raw
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
"github.com/enmanuel/agents/pkg/message"
|
||||
)
|
||||
|
||||
// EventHandler is called for each incoming Matrix message that passes filters.
|
||||
type EventHandler func(ctx context.Context, msgCtx decision.MessageContext, evt *event.Event)
|
||||
|
||||
// Listener attaches to a mautrix syncer and dispatches events to an EventHandler.
|
||||
type Listener struct {
|
||||
client *Client
|
||||
cfg config.MatrixCfg
|
||||
handler EventHandler
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewListener creates a Listener for the given client.
|
||||
func NewListener(client *Client, cfg config.MatrixCfg, handler EventHandler, logger *slog.Logger) *Listener {
|
||||
return &Listener{
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
handler: handler,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the Matrix sync loop. Blocks until ctx is cancelled.
|
||||
func (l *Listener) Run(ctx context.Context) error {
|
||||
syncer := l.client.raw.Syncer.(*mautrix.DefaultSyncer)
|
||||
|
||||
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
|
||||
if !l.shouldHandle(evt) {
|
||||
return
|
||||
}
|
||||
|
||||
body, ok := evt.Content.Raw["body"].(string)
|
||||
if !ok || body == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine power level (simplified — full impl fetches from room state)
|
||||
powerLevel := 0
|
||||
|
||||
isDM := false // TODO: detect DMs via room member count
|
||||
|
||||
opts := message.ParseOptions{
|
||||
CommandPrefix: l.cfg.Filters.CommandPrefix,
|
||||
BotUserID: l.cfg.UserID,
|
||||
}
|
||||
|
||||
msgCtx := message.Parse(
|
||||
body,
|
||||
evt.Sender.String(),
|
||||
evt.RoomID.String(),
|
||||
powerLevel,
|
||||
isDM,
|
||||
opts,
|
||||
)
|
||||
|
||||
go l.handler(ctx, msgCtx, evt)
|
||||
})
|
||||
|
||||
l.logger.Info("starting matrix sync")
|
||||
return l.client.raw.SyncWithContext(ctx)
|
||||
}
|
||||
|
||||
// shouldHandle applies the configured filters to an event.
|
||||
func (l *Listener) shouldHandle(evt *event.Event) bool {
|
||||
f := l.cfg.Filters
|
||||
|
||||
// Don't handle our own messages
|
||||
if evt.Sender == id.UserID(l.cfg.UserID) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore bots
|
||||
if f.IgnoreBots && strings.HasSuffix(evt.Sender.String(), "-bot:"+serverName(l.cfg.UserID)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore specific users
|
||||
for _, u := range f.IgnoreUsers {
|
||||
if evt.Sender.String() == u {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if room is in the listen list
|
||||
if len(l.cfg.Rooms.Listen) > 0 {
|
||||
allowed := false
|
||||
for _, r := range l.cfg.Rooms.Listen {
|
||||
if evt.RoomID.String() == r {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func serverName(userID string) string {
|
||||
parts := strings.SplitN(userID, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package protocols contains adapters for external agent protocols.
|
||||
package protocols
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/enmanuel/agents/pkg/tools"
|
||||
)
|
||||
|
||||
// MCPServer exposes agent tools as an MCP server.
|
||||
type MCPServer struct {
|
||||
srv *server.MCPServer
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewMCPServer creates an MCP server exposing the given tool specs.
|
||||
func NewMCPServer(name, version string, specs []tools.ToolSpec, logger *slog.Logger) *MCPServer {
|
||||
srv := server.NewMCPServer(name, version)
|
||||
|
||||
for _, spec := range specs {
|
||||
spec := spec // capture
|
||||
tool := mcp.NewTool(spec.Name,
|
||||
mcp.WithDescription(spec.Description),
|
||||
)
|
||||
srv.AddTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
// Placeholder handler — wire real execution here
|
||||
return mcp.NewToolResultText(fmt.Sprintf("tool %s called", spec.Name)), nil
|
||||
})
|
||||
}
|
||||
|
||||
return &MCPServer{srv: srv, logger: logger}
|
||||
}
|
||||
|
||||
// ServeStdio runs the MCP server over stdin/stdout (for Claude Desktop / CLI integration).
|
||||
func (m *MCPServer) ServeStdio(ctx context.Context) error {
|
||||
m.logger.Info("mcp server starting on stdio")
|
||||
return server.ServeStdio(m.srv)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package ssh provides impure SSH command execution.
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
"github.com/enmanuel/agents/pkg/tools"
|
||||
)
|
||||
|
||||
// Result holds the output of an SSH command execution.
|
||||
type Result struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
Err error
|
||||
}
|
||||
|
||||
// Executor runs SSH commands against configured targets.
|
||||
type Executor struct {
|
||||
cfg config.SSHCfg
|
||||
}
|
||||
|
||||
// NewExecutor creates an Executor from the SSH config section.
|
||||
func NewExecutor(cfg config.SSHCfg) *Executor {
|
||||
return &Executor{cfg: cfg}
|
||||
}
|
||||
|
||||
// Execute runs the SSH command described by spec. Impure.
|
||||
func (e *Executor) Execute(ctx context.Context, spec tools.SSHCommandSpec) Result {
|
||||
target, ok := e.cfg.Targets[spec.Target]
|
||||
if !ok {
|
||||
return Result{Err: fmt.Errorf("unknown SSH target: %s", spec.Target)}
|
||||
}
|
||||
|
||||
if len(target.Hosts) == 0 {
|
||||
return Result{Err: fmt.Errorf("no hosts for target: %s", spec.Target)}
|
||||
}
|
||||
|
||||
// Use first host (round-robin or load balancing can be added later)
|
||||
host := target.Hosts[0]
|
||||
user := target.User
|
||||
if user == "" {
|
||||
user = e.cfg.Defaults.User
|
||||
}
|
||||
port := target.Port
|
||||
if port == 0 {
|
||||
port = e.cfg.Defaults.Port
|
||||
}
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
|
||||
keyEnv := target.KeyFileEnv
|
||||
if keyEnv == "" {
|
||||
keyEnv = e.cfg.Defaults.KeyFileEnv
|
||||
}
|
||||
|
||||
signer, err := loadSigner(keyEnv)
|
||||
if err != nil {
|
||||
return Result{Err: fmt.Errorf("load SSH key: %w", err)}
|
||||
}
|
||||
|
||||
sshCfg := &gossh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)},
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(), // TODO: use known_hosts
|
||||
Timeout: e.cfg.Defaults.Timeout,
|
||||
}
|
||||
if sshCfg.Timeout == 0 {
|
||||
sshCfg.Timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
conn, err := gossh.Dial("tcp", addr, sshCfg)
|
||||
if err != nil {
|
||||
return Result{Err: fmt.Errorf("ssh dial %s: %w", addr, err)}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
session, err := conn.NewSession()
|
||||
if err != nil {
|
||||
return Result{Err: fmt.Errorf("ssh session: %w", err)}
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
session.Stdout = &stdout
|
||||
session.Stderr = &stderr
|
||||
|
||||
// Respect context cancellation via a goroutine
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- session.Run(spec.Command) }()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.Signal(gossh.SIGTERM)
|
||||
return Result{Err: ctx.Err()}
|
||||
case err := <-done:
|
||||
code := 0
|
||||
if err != nil {
|
||||
var exitErr *gossh.ExitError
|
||||
if ok := asExitError(err, &exitErr); ok {
|
||||
code = exitErr.ExitStatus()
|
||||
} else {
|
||||
return Result{Err: err}
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
ExitCode: code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadSigner(keyFileEnv string) (gossh.Signer, error) {
|
||||
keyPath := os.Getenv(keyFileEnv)
|
||||
if keyPath == "" {
|
||||
return nil, fmt.Errorf("env var %s not set", keyFileEnv)
|
||||
}
|
||||
raw, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gossh.ParsePrivateKey(raw)
|
||||
}
|
||||
|
||||
// asExitError is a helper for type-asserting ssh.ExitError.
|
||||
func asExitError(err error, target **gossh.ExitError) bool {
|
||||
e, ok := err.(*gossh.ExitError)
|
||||
if ok {
|
||||
*target = e
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// Ensure net is used (for future jump host support)
|
||||
var _ = net.Dial
|
||||
Reference in New Issue
Block a user