Files
agents_and_robots/cmd/launcher/main.go
T
egutierrez 261f96f71b feat(api): per-agent unified control + clear_memory + delete_cache
- Manager: RegisterUnifiedAgent/UnregisterUnifiedAgent/StopUnifiedAgent/
  IsUnifiedAgentRunning/UptimeSeconds — cancela goroutines individuales sin
  matar el launcher
- Manager: UptimeSeconds en AgentStatus via startedAt map
- api/server: AgentController interface + WithController/WithDataDir builders
  + rutas POST /agents/{id}/clear_memory y /agents/{id}/delete_cache
- api/handlers: handleStartAgent/Stop/Restart delegan a controller en modo
  unified; Messages24h enriquecido via queryMessages24h (cache 30s)
- api/handlers: handleClearMemory — para la goroutine, borra messages+facts de
  memory.db, responde {status,messages_deleted,facts_deleted}
- api/handlers: handleDeleteCache — para la goroutine, elimina crypto/ y cache/,
  responde {status,paths_deleted}
- launcher/registry: launchGoroutine extrae goroutine con contexto per-agente;
  deps.procMgr hookea RegisterUnified; startAgent permite relanzar via reload
- launcher/main: agentController implementa api.AgentController sobre registry;
  mgr compartido entre API y registry; WithController+WithDataDir cableados

Co-Authored-By: fn-orquestador <noreply@fn-registry>
2026-05-22 22:56:46 +02:00

439 lines
14 KiB
Go

// Command launcher starts one or more agents from their config files.
//
// Usage:
//
// go run ./cmd/launcher # auto-discovers agents/*/config.yaml
// go run ./cmd/launcher -c agents/assistant/config.yaml
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"maunium.net/go/mautrix"
"github.com/spf13/cobra"
"github.com/enmanuel/agents/devagents"
"github.com/enmanuel/agents/internal/api"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/pkg/decision"
"github.com/enmanuel/agents/pkg/orchestration"
pksecurity "github.com/enmanuel/agents/pkg/security"
"github.com/enmanuel/agents/shell/bus"
agentlog "github.com/enmanuel/agents/shell/logger"
orchshell "github.com/enmanuel/agents/shell/orchestration"
shellsecurity "github.com/enmanuel/agents/shell/security"
"github.com/enmanuel/agents/shell/process"
// Blank imports: each agent self-registers its rules via init().
_ "github.com/enmanuel/agents/agents/assistant-bot"
_ "github.com/enmanuel/agents/agents/asistente-2"
_ "github.com/enmanuel/agents/agents/meteorologo"
_ "github.com/enmanuel/agents/agents/test-personality"
_ "github.com/enmanuel/agents/agents/_specials/father-bot"
_ "github.com/enmanuel/agents/agents/wikipedia-bot"
_ "github.com/enmanuel/agents/agents/exchange-bot"
_ "github.com/enmanuel/agents/agents/reminder-bot"
testbot "github.com/enmanuel/agents/agents/test-bot"
)
func main() {
var (
configPaths []string
logLevel string
logDir string
apiPort int
apiKey string
)
root := &cobra.Command{
Use: "launcher",
Short: "Start Matrix agents from config files",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if len(configPaths) == 0 {
matches, _ := filepath.Glob("agents/*/config.yaml")
configPaths = matches
// Also discover agent-type specials (e.g. father-bot).
// SpecialConfig middleware (orchestrator) is handled separately.
specials, _ := filepath.Glob("agents/_specials/*/config.yaml")
configPaths = append(configPaths, specials...)
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
lvl := parseLogLevel(logLevel)
// ── Launcher-level logger ──
logger, launcherCleanup, err := agentlog.NewAgentLogger(agentlog.LoggerConfig{
BaseDir: logDir,
AgentID: "launcher",
Level: lvl,
})
if err != nil {
// Fallback to stdout if file logger fails.
logger = newLogger(logLevel)
logger.Warn("could not create file logger, falling back to stdout", "err", err)
launcherCleanup = func() {}
}
defer launcherCleanup()
if len(configPaths) == 0 {
logger.Warn("no agent configs found — nothing to start")
return nil
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// ── Load centralized security policy ──
secPolicy, secErr := shellsecurity.Load("security/")
if secErr != nil {
logger.Warn("security policy load failed, using empty policy (open access)", "err", secErr)
secPolicy = pksecurity.SecurityPolicy{}
} else {
logger.Info("security policy loaded",
"user_groups", len(secPolicy.UserGroups),
"agent_groups", len(secPolicy.AgentGroups),
"policies", len(secPolicy.Policies),
)
}
// ── Shared bus for inter-agent communication ──
agentBus := bus.New(logger)
// ── Start special agents (orchestrator, etc.) BEFORE normal bots ──
orch, err := startOrchestrator(agentBus, logger)
if err != nil {
// Non-fatal: orchestration is optional
logger.Warn("orchestrator not started", "err", err)
} else if orch != nil {
logger.Info("orchestrator initialized")
}
// ── Process manager (shared: API reflection + per-agent goroutine hooks) ──
mgr := newProcessManager(logDir)
// ── Shared dependencies for agent registry ──
deps := &launchDeps{
agentBus: agentBus,
orch: orch,
logDir: logDir,
logLevel: lvl,
parentCtx: ctx,
secPolicy: secPolicy,
procMgr: mgr,
}
registry := newAgentRegistry(deps)
// ── SIGHUP: hot-reload individual agent or all agents ──
sighup := make(chan os.Signal, 1)
signal.Notify(sighup, syscall.SIGHUP)
go func() {
for {
select {
case <-ctx.Done():
return
case _, ok := <-sighup:
if !ok {
return
}
id := readReloadTarget("run/reload.txt")
// Remove the target file after reading so it doesn't
// affect the next SIGHUP.
_ = os.Remove("run/reload.txt")
if id == "" {
logger.Info("sighup: reloading all agents")
registry.reloadAll(rulesFor)
} else {
logger.Info("sighup: reloading agent", "id", id)
registry.reload(id, rulesFor)
}
}
}
}()
// ── Start normal agents ──
// Build a set of special IDs already loaded (e.g. orchestrator)
// so the discovery loop skips them instead of failing on validation.
loadedSpecials := make(map[string]bool)
if orch != nil {
loadedSpecials[orch.cfg.Special.ID] = true
}
var scannerOnce scanOnce
for _, path := range configPaths {
path := path
// Skip configs that belong to already-loaded specials.
if isSpecialConfig(path, loadedSpecials) {
continue
}
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
}
if cfg.Agent.Template {
logger.Info("agent is template, skipping", "id", cfg.Agent.ID)
continue
}
// Per-agent logger → writes to logs/<agent-id>/YYYY-MM-DD.jsonl
agentLogger, agentCleanup, aErr := agentlog.NewAgentLogger(agentlog.LoggerConfig{
BaseDir: logDir,
AgentID: cfg.Agent.ID,
Level: lvl,
})
if aErr != nil {
logger.Warn("agent file logger failed, using launcher logger", "agent", cfg.Agent.ID, "err", aErr)
agentLogger = logger.With("agent", cfg.Agent.ID)
agentCleanup = func() {}
}
// Branch: robot (command-only, lightweight) vs agent (full runtime).
var runner devagents.Runner
if cfg.Agent.Type == "robot" {
robot, rErr := devagents.NewRobot(cfg, agentLogger)
if rErr != nil {
logger.Error("failed to create robot", "id", cfg.Agent.ID, "err", rErr)
agentCleanup()
continue
}
// Register agent-specific commands for robots
if cfg.Agent.ID == "test-bot" {
for _, cmd := range testbot.Commands() {
robot.RegisterCommand(cmd.Spec, cmd.Handler)
}
}
runner = robot
agentLogger.Info("created robot", "id", cfg.Agent.ID)
} else {
rules := rulesFor(cfg.Agent.ID, logger)
// Resolve centralized ACL for this agent
agentACL := pksecurity.ResolveACL(cfg.Agent.ID, deps.secPolicy)
agentLogger.Debug("resolved acl for agent",
"agent", cfg.Agent.ID,
"acl_empty", agentACL.Empty(),
)
a, cErr := devagents.New(cfg, rules, agentACL, agentLogger, devagents.WithLogDir(logDir))
if cErr != nil {
logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", cErr)
agentCleanup()
continue
}
// Connect agent to bus for orchestration
a.SetBus(agentBus)
// If orchestrator is active, wire interceptor and membership notify
if orch != nil {
a.SetInterceptor(orch.orchestrator.Intercept)
a.SetMembershipNotify(orch.orchestrator.NotifyMembership)
orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{
ID: cfg.Agent.ID,
MatrixUserID: cfg.Matrix.UserID,
Description: cfg.Agent.Description,
Capabilities: cfg.Agent.Tags,
})
// Grab the first available Matrix client for room scanning
scannerOnce.set(a.RawMatrixClient())
}
runner = a
}
registry.register(&runningAgent{
runner: runner,
cfg: cfg,
cfgPath: path,
logger: agentLogger,
logCleanup: agentCleanup,
})
}
// ── Startup room scan (after all participants are registered) ──
if orch != nil && scannerOnce.client != nil {
orch.orchestrator.SetScanner(scannerOnce.client)
scanCtx, scanCancel := context.WithTimeout(ctx, 30*time.Second)
orch.orchestrator.ScanExistingRooms(scanCtx)
scanCancel()
}
// ── HTTP API (optional) ──
if apiPort > 0 {
key := apiKey
if key == "" {
key = os.Getenv("AGENTS_API_KEY")
}
if key == "" {
logger.Warn("api-port set but AGENTS_API_KEY is empty — HTTP API disabled (set AGENTS_API_KEY in .env)")
} else {
// mgr already created above; share it between API and registry.
ctrl := &agentController{reg: registry, mgr: mgr}
srv := api.New(mgr, key, apiPort, logger).
WithController(ctrl).
WithDataDir("agents")
go func() {
if err := srv.Run(ctx); err != nil {
logger.Error("api server stopped", "err", err)
}
}()
logger.Info("http api enabled", "port", apiPort)
}
}
// Supervised loop: wait for all agents, and if the parent context is
// still alive (i.e. no SIGINT/SIGTERM received), reload them and keep
// going. Protects against the launcher exiting cleanly when all
// agent runners terminate naturally (token rotation, sync drop, etc.)
// while the supervisor itself is healthy.
registry.superviseUntilCanceled(ctx, 5*time.Second, rulesFor, logger)
registry.cleanupLogs()
logger.Info("launcher shutting down")
return nil
},
}
root.Flags().StringSliceVarP(&configPaths, "config", "c", nil,
"Agent config file(s). If omitted, discovers all agents/*/config.yaml")
root.Flags().StringVar(&logLevel, "log-level", "info",
"Log level: debug | info | warn | error")
root.Flags().StringVar(&logDir, "log-dir", "logs",
`Log directory (logs/<agent>/YYYY-MM-DD.jsonl). Use "stdout" for console only`)
root.Flags().IntVar(&apiPort, "api-port", 0,
"HTTP API port (0 = disabled). Requires AGENTS_API_KEY env var.")
root.Flags().StringVar(&apiKey, "api-key", "",
"HTTP API Bearer key (overrides AGENTS_API_KEY env var)")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// scanOnce captures the first Matrix client for room scanning.
type scanOnce struct {
client *mautrix.Client
}
func (s *scanOnce) set(c *mautrix.Client) {
if s.client == nil {
s.client = c
}
}
// orchHandle wraps a running orchestrator with its config for the launcher.
type orchHandle struct {
orchestrator *orchshell.Orchestrator
cfg *config.SpecialConfig
}
// startOrchestrator scans agents/_specials/orchestrator/config.yaml and
// initializes the orchestrator if found and enabled.
func startOrchestrator(agentBus *bus.Bus, logger *slog.Logger) (*orchHandle, error) {
cfgPath := filepath.Join("agents", "_specials", "orchestrator", "config.yaml")
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
return nil, err
}
cfg, err := config.LoadSpecial(cfgPath)
if err != nil {
return nil, err
}
if !cfg.Special.Enabled {
return nil, nil
}
orchLogger := logger.With("component", "orchestrator")
orch, err := orchshell.New(cfg, agentBus, orchLogger)
if err != nil {
return nil, err
}
return &orchHandle{orchestrator: orch, cfg: cfg}, nil
}
// rulesFor retrieves the rule factory for the given agent ID from the
// global registry (populated by init() in each agent package).
// Returns nil if no rules are registered (command-only bot).
func rulesFor(agentID string, logger *slog.Logger) []decision.Rule {
factory := devagents.GetRules(agentID)
if factory == nil {
logger.Warn("no rules registered for agent, using empty ruleset (command-only)", "id", agentID)
return nil
}
return factory()
}
func parseLogLevel(level string) slog.Level {
switch level {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// newLogger creates a stdout-only JSON logger (fallback when file logger fails).
func newLogger(level string) *slog.Logger {
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLogLevel(level)}))
}
// newProcessManager creates a process.Manager scoped to the current working
// directory, used by the HTTP API to reflect the live launcher state.
func newProcessManager(logDir string) *process.Manager {
return process.NewManager("run", "agents/*/config.yaml", "bin/launcher")
}
// agentController adapts agentRegistry + process.Manager to the api.AgentController
// interface, allowing the HTTP API to start/stop individual agent goroutines without
// restarting the whole launcher process.
type agentController struct {
reg *agentRegistry
mgr *process.Manager
}
// StopUnifiedAgent cancels the per-agent goroutine context without stopping the launcher.
func (c *agentController) StopUnifiedAgent(id string) error {
return c.mgr.StopUnifiedAgent(id)
}
// StartUnifiedAgent re-launches the agent goroutine for the given ID.
func (c *agentController) StartUnifiedAgent(id string) error {
return c.reg.startAgent(id, rulesFor)
}
// isSpecialConfig checks whether a config path belongs to a middleware special
// (e.g. orchestrator) by detecting a "special:" top-level key with a non-empty
// id. This avoids config.Load() failing with "agent.id is required" when the
// orchestrator is disabled or failed to start (loadedSpecials would be empty).
// Agent-type specials like father-bot use a regular agent config (agent.id set)
// and are handled by the normal loading path.
func isSpecialConfig(path string, _ map[string]bool) bool {
cfg, err := config.LoadSpecial(path)
if err != nil {
return false // not a valid special config → let Load() handle it
}
return cfg.Special.ID != "" // has special.id → middleware special, skip
}