bd8e1432e5
- Implemented the assistant bot with basic command handling and LLM routing. - Created configuration file for the assistant bot with personality, behavior, and LLM settings. - Added system prompt for the assistant bot to define its capabilities and limitations. - Developed registration script for creating Matrix bot users via Synapse admin API. - Introduced common development scripts for agent management (start, stop, list, logs). - Scaffolded new agent creation script to streamline the addition of new agents. - Implemented agent removal script to disable agents without deleting data.
397 lines
10 KiB
Go
397 lines
10 KiB
Go
// Command agentctl manages Matrix agents: list, start, stop, remove.
|
|
//
|
|
// Usage:
|
|
//
|
|
// agentctl list # all agents with their status
|
|
// agentctl start # start all enabled agents
|
|
// agentctl start assistant-bot # start a specific agent
|
|
// agentctl stop # stop all running agents
|
|
// agentctl stop assistant-bot # stop a specific agent
|
|
// agentctl remove assistant-bot # disable agent (keeps data)
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/enmanuel/agents/internal/config"
|
|
)
|
|
|
|
const (
|
|
runDir = "run" // PID + log files
|
|
agentsGlob = "agents/*/config.yaml"
|
|
)
|
|
|
|
// ── entry point ───────────────────────────────────────────────────────────
|
|
|
|
func main() {
|
|
var binPath string
|
|
|
|
root := &cobra.Command{
|
|
Use: "agentctl",
|
|
Short: "Manage Matrix agents",
|
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
|
return os.MkdirAll(runDir, 0o755)
|
|
},
|
|
}
|
|
|
|
root.PersistentFlags().StringVar(&binPath, "bin", "",
|
|
"Launcher binary path. Defaults to ./bin/launcher, falls back to 'go run ./cmd/launcher'")
|
|
|
|
root.AddCommand(
|
|
listCmd(),
|
|
startCmd(&binPath),
|
|
stopCmd(),
|
|
removeCmd(),
|
|
)
|
|
|
|
if err := root.Execute(); err != nil {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// ── list ──────────────────────────────────────────────────────────────────
|
|
|
|
func listCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "list",
|
|
Short: "List all agents and their current status",
|
|
Aliases: []string{"ls"},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
agents, err := scanAgents()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(agents) == 0 {
|
|
fmt.Println("No agents found under agents/*/config.yaml")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("%-20s %-12s %-8s %s\n", "ID", "STATUS", "VERSION", "DESCRIPTION")
|
|
fmt.Println(strings.Repeat("─", 72))
|
|
for _, a := range agents {
|
|
fmt.Printf("%-20s %-12s %-8s %s\n",
|
|
a.ID,
|
|
statusLabel(a),
|
|
a.Version,
|
|
truncate(a.Desc, 36),
|
|
)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── start ─────────────────────────────────────────────────────────────────
|
|
|
|
func startCmd(binPath *string) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "start [agent-id...]",
|
|
Short: "Start one or all enabled agents",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
agents, err := scanAgents()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
targets := filterTargets(agents, args)
|
|
if len(targets) == 0 {
|
|
return fmt.Errorf("no matching agents found")
|
|
}
|
|
|
|
bin := resolvedBin(*binPath)
|
|
started := 0
|
|
|
|
for _, a := range targets {
|
|
if !a.Enabled {
|
|
fmt.Printf("skip %-20s (disabled in config)\n", a.ID)
|
|
continue
|
|
}
|
|
if isRunning(a.ID) {
|
|
fmt.Printf("skip %-20s (already running, PID %d)\n", a.ID, readPID(a.ID))
|
|
continue
|
|
}
|
|
|
|
if err := startAgent(a, bin); err != nil {
|
|
fmt.Fprintf(os.Stderr, "fail %-20s %v\n", a.ID, err)
|
|
continue
|
|
}
|
|
|
|
fmt.Printf("start %-20s PID %d log → %s\n",
|
|
a.ID, readPID(a.ID), logPath(a.ID))
|
|
started++
|
|
}
|
|
|
|
if started == 0 {
|
|
fmt.Println("Nothing started.")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── stop ──────────────────────────────────────────────────────────────────
|
|
|
|
func stopCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "stop [agent-id...]",
|
|
Short: "Stop one or all running agents",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
agents, err := scanAgents()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
targets := filterTargets(agents, args)
|
|
if len(targets) == 0 {
|
|
return fmt.Errorf("no matching agents found")
|
|
}
|
|
|
|
stopped := 0
|
|
for _, a := range targets {
|
|
pid := readPID(a.ID)
|
|
if pid == 0 || !isRunning(a.ID) {
|
|
fmt.Printf("skip %-20s (not running)\n", a.ID)
|
|
continue
|
|
}
|
|
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
|
fmt.Fprintf(os.Stderr, "fail %-20s kill: %v\n", a.ID, err)
|
|
continue
|
|
}
|
|
removePIDFile(a.ID)
|
|
fmt.Printf("stop %-20s sent SIGTERM to PID %d\n", a.ID, pid)
|
|
stopped++
|
|
}
|
|
|
|
if stopped == 0 {
|
|
fmt.Println("Nothing stopped.")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── remove ────────────────────────────────────────────────────────────────
|
|
|
|
func removeCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "remove <agent-id>",
|
|
Short: "Disable an agent (sets enabled: false). Does not delete data.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
id := args[0]
|
|
|
|
agents, err := scanAgents()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var target *agentInfo
|
|
for i := range agents {
|
|
if agents[i].ID == id {
|
|
target = &agents[i]
|
|
break
|
|
}
|
|
}
|
|
if target == nil {
|
|
return fmt.Errorf("agent %q not found", id)
|
|
}
|
|
|
|
// Stop if running
|
|
if isRunning(id) {
|
|
pid := readPID(id)
|
|
_ = syscall.Kill(pid, syscall.SIGTERM)
|
|
removePIDFile(id)
|
|
fmt.Printf("stop %-20s sent SIGTERM to PID %d\n", id, pid)
|
|
}
|
|
|
|
// Disable in config (preserves comments)
|
|
if err := setEnabled(target.ConfigPath, false); err != nil {
|
|
return fmt.Errorf("update config: %w", err)
|
|
}
|
|
|
|
fmt.Printf("ok %-20s marked as disabled in %s\n", id, target.ConfigPath)
|
|
fmt.Printf(" Data preserved at agents/%s/data/\n", id)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── agent scanning ────────────────────────────────────────────────────────
|
|
|
|
type agentInfo struct {
|
|
ID string
|
|
Version string
|
|
Enabled bool
|
|
Desc string
|
|
ConfigPath string
|
|
}
|
|
|
|
func scanAgents() ([]agentInfo, error) {
|
|
matches, err := filepath.Glob(agentsGlob)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var agents []agentInfo
|
|
for _, path := range matches {
|
|
// Use LoadMeta so list works even when env vars aren't set.
|
|
cfg, err := config.LoadMeta(path)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warn skipping %s: %v\n", path, err)
|
|
continue
|
|
}
|
|
agents = append(agents, agentInfo{
|
|
ID: cfg.Agent.ID,
|
|
Version: cfg.Agent.Version,
|
|
Enabled: cfg.Agent.Enabled,
|
|
Desc: cfg.Agent.Description,
|
|
ConfigPath: path,
|
|
})
|
|
}
|
|
return agents, nil
|
|
}
|
|
|
|
func filterTargets(agents []agentInfo, ids []string) []agentInfo {
|
|
if len(ids) == 0 {
|
|
return agents // no filter → all
|
|
}
|
|
set := make(map[string]bool, len(ids))
|
|
for _, id := range ids {
|
|
set[id] = true
|
|
}
|
|
var out []agentInfo
|
|
for _, a := range agents {
|
|
if set[a.ID] {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── process management ────────────────────────────────────────────────────
|
|
|
|
func startAgent(a agentInfo, bin string) error {
|
|
logFile, err := os.OpenFile(logPath(a.ID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return fmt.Errorf("open log: %w", err)
|
|
}
|
|
|
|
var cmd *exec.Cmd
|
|
if strings.HasPrefix(bin, "go run") {
|
|
// dev mode: go run ./cmd/launcher -c <config>
|
|
cmd = exec.Command("go", "run", "./cmd/launcher", "-c", a.ConfigPath)
|
|
} else {
|
|
cmd = exec.Command(bin, "-c", a.ConfigPath)
|
|
}
|
|
|
|
cmd.Stdout = logFile
|
|
cmd.Stderr = logFile
|
|
// Detach from the parent process group so it keeps running after agentctl exits
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
logFile.Close()
|
|
return fmt.Errorf("exec: %w", err)
|
|
}
|
|
|
|
// Write PID file — the subprocess owns its lifecycle now
|
|
if err := os.WriteFile(pidPath(a.ID), []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil {
|
|
return fmt.Errorf("write PID: %w", err)
|
|
}
|
|
|
|
// Detach: don't wait for the process
|
|
go func() { _ = cmd.Wait() }()
|
|
|
|
return nil
|
|
}
|
|
|
|
func isRunning(id string) bool {
|
|
pid := readPID(id)
|
|
if pid == 0 {
|
|
return false
|
|
}
|
|
err := syscall.Kill(pid, 0) // signal 0 checks existence without killing
|
|
return err == nil
|
|
}
|
|
|
|
func readPID(id string) int {
|
|
raw, err := os.ReadFile(pidPath(id))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
pid, _ := strconv.Atoi(strings.TrimSpace(string(raw)))
|
|
return pid
|
|
}
|
|
|
|
func removePIDFile(id string) {
|
|
_ = os.Remove(pidPath(id))
|
|
}
|
|
|
|
func pidPath(id string) string { return filepath.Join(runDir, id+".pid") }
|
|
func logPath(id string) string { return filepath.Join(runDir, id+".log") }
|
|
|
|
// ── config editing ────────────────────────────────────────────────────────
|
|
|
|
// setEnabled flips `enabled: true/false` in the agent section of the YAML.
|
|
// Uses text replacement to preserve all comments.
|
|
func setEnabled(configPath string, enabled bool) error {
|
|
raw, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
current := "enabled: true"
|
|
replacement := "enabled: false"
|
|
if enabled {
|
|
current = "enabled: false"
|
|
replacement = "enabled: true"
|
|
}
|
|
|
|
updated := strings.Replace(string(raw), current, replacement, 1)
|
|
if updated == string(raw) {
|
|
return nil // already in the desired state
|
|
}
|
|
|
|
return os.WriteFile(configPath, []byte(updated), 0o644)
|
|
}
|
|
|
|
// ── display helpers ───────────────────────────────────────────────────────
|
|
|
|
func statusLabel(a agentInfo) string {
|
|
switch {
|
|
case !a.Enabled:
|
|
return "disabled"
|
|
case isRunning(a.ID):
|
|
return "● running"
|
|
default:
|
|
return "○ stopped"
|
|
}
|
|
}
|
|
|
|
func truncate(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max-1] + "…"
|
|
}
|
|
|
|
// resolvedBin returns the launcher binary path to use.
|
|
// Priority: --bin flag > ./bin/launcher (if exists) > go run fallback.
|
|
func resolvedBin(flagVal string) string {
|
|
if flagVal != "" {
|
|
return flagVal
|
|
}
|
|
if _, err := os.Stat("bin/launcher"); err == nil {
|
|
return "bin/launcher"
|
|
}
|
|
return "go run ./cmd/launcher"
|
|
}
|