Repo iniciado

This commit is contained in:
2026-03-03 23:19:23 +00:00
commit c126187c5a
32 changed files with 2719 additions and 0 deletions
+34
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
*.db
*.log
data/
bin/
+111
View File
@@ -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{},
}},
},
}
}
+334
View File
@@ -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
+24
View File
@@ -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.
+145
View File
@@ -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
}
+80
View File
@@ -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
},
}
}
+105
View File
@@ -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,
},
}},
},
}
}
+14
View File
@@ -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}"
+28
View File
@@ -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
+39
View File
@@ -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
)
+88
View File
@@ -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=
+47
View File
@@ -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
}
+384
View File
@@ -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"`
}
+76
View File
@@ -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
}
}
+64
View File
@@ -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
}
+25
View File
@@ -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
}
+68
View File
@@ -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)
+28
View File
@@ -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
}
+43
View File
@@ -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
}
+93
View File
@@ -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,
},
}
}
+58
View File
@@ -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
}
+37
View File
@@ -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
}
+64
View File
@@ -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)
}
}
+78
View File
@@ -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)}
}
}
+146
View File
@@ -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
}
+39
View File
@@ -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
}
}
+76
View File
@@ -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
}
}
+75
View File
@@ -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
}
+122
View File
@@ -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 ""
}
+43
View File
@@ -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)
}
+146
View File
@@ -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