commit c126187c5a7a99734c05b04046b568bb729de120 Author: Enmanuel Date: Tue Mar 3 23:19:23 2026 +0000 Repo iniciado diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..39f1f3d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09e51f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +*.db +*.log +data/ +bin/ diff --git a/agents/devops/agent.go b/agents/devops/agent.go new file mode 100644 index 0000000..38df75d --- /dev/null +++ b/agents/devops/agent.go @@ -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 ` — estado del target\n" + + "- `!deploy ` — deployment en el environment\n" + + "- `!rollback ` — rollback del último deploy\n" + + "- `!logs ` — ú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{}, + }}, + }, + } +} diff --git a/agents/devops/config.yaml b/agents/devops/config.yaml new file mode 100644 index 0000000..f8a01ee --- /dev/null +++ b/agents/devops/config.yaml @@ -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 diff --git a/agents/devops/prompts/devops-system.md b/agents/devops/prompts/devops-system.md new file mode 100644 index 0000000..c41c33a --- /dev/null +++ b/agents/devops/prompts/devops-system.md @@ -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. diff --git a/agents/runtime.go b/agents/runtime.go new file mode 100644 index 0000000..aa8d325 --- /dev/null +++ b/agents/runtime.go @@ -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 +} diff --git a/cmd/agentctl/main.go b/cmd/agentctl/main.go new file mode 100644 index 0000000..42b5ffc --- /dev/null +++ b/cmd/agentctl/main.go @@ -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 + }, + } +} diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go new file mode 100644 index 0000000..69aa0e1 --- /dev/null +++ b/cmd/launcher/main.go @@ -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, + }, + }}, + }, + } +} diff --git a/config/matrix.yaml b/config/matrix.yaml new file mode 100644 index 0000000..c077ddf --- /dev/null +++ b/config/matrix.yaml @@ -0,0 +1,14 @@ +# Global Matrix configuration +# Agent-specific overrides go in agents//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}" diff --git a/config/servers.yaml b/config/servers.yaml new file mode 100644 index 0000000..5ccefc7 --- /dev/null +++ b/config/servers.yaml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2634c54 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..16a03c1 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..6f84356 --- /dev/null +++ b/internal/config/loader.go @@ -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 +} diff --git a/internal/config/schema.go b/internal/config/schema.go new file mode 100644 index 0000000..63e3dfa --- /dev/null +++ b/internal/config/schema.go @@ -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"` +} diff --git a/pkg/decision/engine.go b/pkg/decision/engine.go new file mode 100644 index 0000000..a1e9d07 --- /dev/null +++ b/pkg/decision/engine.go @@ -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 + } +} diff --git a/pkg/decision/types.go b/pkg/decision/types.go new file mode 100644 index 0000000..81244b6 --- /dev/null +++ b/pkg/decision/types.go @@ -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 +} diff --git a/pkg/llm/router.go b/pkg/llm/router.go new file mode 100644 index 0000000..172e162 --- /dev/null +++ b/pkg/llm/router.go @@ -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 +} diff --git a/pkg/llm/types.go b/pkg/llm/types.go new file mode 100644 index 0000000..fc96628 --- /dev/null +++ b/pkg/llm/types.go @@ -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) diff --git a/pkg/message/format.go b/pkg/message/format.go new file mode 100644 index 0000000..9edb6df --- /dev/null +++ b/pkg/message/format.go @@ -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 +} diff --git a/pkg/message/parse.go b/pkg/message/parse.go new file mode 100644 index 0000000..daf3298 --- /dev/null +++ b/pkg/message/parse.go @@ -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 +} diff --git a/pkg/personality/traits.go b/pkg/personality/traits.go new file mode 100644 index 0000000..fa9a4e3 --- /dev/null +++ b/pkg/personality/traits.go @@ -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, + }, + } +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go new file mode 100644 index 0000000..14eefdf --- /dev/null +++ b/pkg/tools/registry.go @@ -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 +} diff --git a/pkg/tools/specs.go b/pkg/tools/specs.go new file mode 100644 index 0000000..ee7d2e8 --- /dev/null +++ b/pkg/tools/specs.go @@ -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 +} diff --git a/shell/bus/bus.go b/shell/bus/bus.go new file mode 100644 index 0000000..9305406 --- /dev/null +++ b/shell/bus/bus.go @@ -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) + } +} diff --git a/shell/effects/runner.go b/shell/effects/runner.go new file mode 100644 index 0000000..3f14fd3 --- /dev/null +++ b/shell/effects/runner.go @@ -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)} + } +} diff --git a/shell/llm/anthropic.go b/shell/llm/anthropic.go new file mode 100644 index 0000000..932b333 --- /dev/null +++ b/shell/llm/anthropic.go @@ -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 +} diff --git a/shell/llm/factory.go b/shell/llm/factory.go new file mode 100644 index 0000000..82af76b --- /dev/null +++ b/shell/llm/factory.go @@ -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 + } +} diff --git a/shell/llm/openai.go b/shell/llm/openai.go new file mode 100644 index 0000000..c9a2046 --- /dev/null +++ b/shell/llm/openai.go @@ -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 + } +} diff --git a/shell/matrix/client.go b/shell/matrix/client.go new file mode 100644 index 0000000..adcfb4b --- /dev/null +++ b/shell/matrix/client.go @@ -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 +} diff --git a/shell/matrix/listener.go b/shell/matrix/listener.go new file mode 100644 index 0000000..773caab --- /dev/null +++ b/shell/matrix/listener.go @@ -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 "" +} diff --git a/shell/protocols/mcp.go b/shell/protocols/mcp.go new file mode 100644 index 0000000..7117487 --- /dev/null +++ b/shell/protocols/mcp.go @@ -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) +} diff --git a/shell/ssh/executor.go b/shell/ssh/executor.go new file mode 100644 index 0000000..a637f9b --- /dev/null +++ b/shell/ssh/executor.go @@ -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