diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..e902814 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,114 @@ +# Agents + +An agent is a flat markdown file the runtime can execute. The file is the whole +definition: frontmatter for metadata and policy, a body of instructions. The +runtime is the layer underneath, shared with the assistant and the MCP server: +the tool loop, permission gates, protected mode, secret redaction, and per-call +approval before any state change. + +This document describes the architecture: what ships today and the design the +next slices build toward, so complex, long-running workflows compose from the +same small parts. + +## The file + +```markdown +--- +description: Fix log errors and shepherd the fix through review +scope: deployment +deployment: my-api +--- +Read the recent logs. If a recurring error has a clear fix, produce it, +open a pull request, and follow the reviews. Merge only when CI is green +and one approval exists. If the change touches auth or payment code, +never merge; wait for the operator. +``` + +Files live in `/.flatrun/agents/*.md`, named by basename. No +registration: drop a file in, it lists; delete it, it is gone. A bare markdown +file with no frontmatter is a system-scoped agent. + +## Creating agents + +Three equivalent paths, all producing the same file: + +1. **Ask the assistant.** Describe the agent you want in chat; the assistant + drafts the file and writes it with the `write_agent_file` tool, which is + state-changing and therefore waits for your approval. +2. **The Agents view.** Write or edit the file in the panel. +3. **The filesystem.** Any editor, `git`, CI, or another agent. It is a plain + file. + +Because `write_agent_file` is just another tool, an agent can create or refine +other agents, including its own definition, each change gated by approval like +any other write. That is the loop that lets a system improve itself without +ever changing anything silently. + +## A run is a session + +Running an agent seeds an AI session with its instructions and advances the +shared tool loop. Every session guarantee applies unchanged, and each run +records the agent it belongs to, so an agent's run history is its sessions. + +Statuses today: `ready` (turn finished), `awaiting_approval` (a state-changing +tool is waiting for a per-call decision). Planned: `waiting` (the run parked +itself for an event or schedule) and `completed`, yielded by two built-in +control tools, `wait_for_event(reason)` and `finish(summary)`, so a run can +span days without holding anything open. + +## Policy is deterministic; instructions are advisory + +The split that makes guarantees real. Soft conditions ("merge when CI is green +and one approval exists") live in the instructions; the model applies them. +Hard floors live in frontmatter policy, enforced by the runtime before any tool +runs: + +```yaml +policy: + auto_approve: [github__create_pull_request] # runs without asking + require_approval: [github__merge_pull_request] # always asks, regardless + deny: [control_deployment] # not even registered +``` + +The default is unchanged: any state-changing tool pauses for per-call operator +approval. Policy can only widen approval upward (`auto_approve`) for named +tools, or harden it (`require_approval`, `deny`). A `dry_run` run auto-declines +every mutation and reports what would have happened. + +## External tools over MCP + +Planned frontmatter: + +```yaml +mcp_servers: + - name: github + url: https://api.githubcopilot.com/mcp/ + credential: github-mcp # credential manager ID, never a secret inline +``` + +Each server's tools are namespaced (`github__merge_pull_request`) and flow +through the same policy gate. Anything that speaks MCP, a coding agent, an +issue tracker, a deployment target, becomes available without per-integration +code. FlatRun already serves its own tools over MCP; this is the client side. + +## Triggers and the wake cycle + +Planned: `triggers` frontmatter for schedules (via the existing scheduler) and +webhooks (`POST /api/ai/agents/:name/events`, per-agent secret). A trigger +appends the event payload to the session as a new turn, redacted, and advances +the loop again with the full history as memory. Manual runs stay as they are. + +## Budgets + +`max_steps` caps tool rounds per wake (default 8). Planned: max wakes and a +deadline per run. The session message window is already capped. A loop cannot +run away. + +## Delivery slices + +1. Frontmatter policy (`auto_approve` / `require_approval` / `deny`), + `max_steps`, `dry_run` runs. +2. Run lifecycle: `wait_for_event` / `finish`, `waiting` / `completed` + statuses, the events endpoint. +3. MCP client and `mcp_servers`. +4. Triggers: scheduler integration and webhook secrets; run-history UI. diff --git a/internal/ai/agent.go b/internal/ai/agent.go new file mode 100644 index 0000000..8596d7d --- /dev/null +++ b/internal/ai/agent.go @@ -0,0 +1,162 @@ +package ai + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// Agent is an agent definition: a flat markdown file with YAML frontmatter for +// metadata and a body of instructions. The runtime executes it through the +// shared tool set; the file is the whole definition, so it can be read, +// edited, and versioned like any other deployment file. +type Agent struct { + Name string `json:"name" yaml:"-"` + Description string `json:"description" yaml:"description"` + Scope string `json:"scope" yaml:"scope"` + Deployment string `json:"deployment,omitempty" yaml:"deployment"` + Instructions string `json:"-" yaml:"-"` +} + +var frontmatterPattern = regexp.MustCompile(`(?s)\A---\s*\n(.*?)\n---\s*\n?`) + +// agentNamePattern keeps agent names path-safe: they become filenames and URL +// segments. +var agentNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) + +// ParseAgent reads an agent definition. Frontmatter is optional; a bare +// markdown file is a system-scoped agent whose whole content is the +// instructions. +func ParseAgent(name, content string) (*Agent, error) { + agent := &Agent{Name: name, Scope: SessionScopeSystem} + body := content + if m := frontmatterPattern.FindStringSubmatch(content); m != nil { + if err := yaml.Unmarshal([]byte(m[1]), agent); err != nil { + return nil, fmt.Errorf("invalid frontmatter: %w", err) + } + body = content[len(m[0]):] + } + agent.Name = name + if agent.Scope == "" { + agent.Scope = SessionScopeSystem + } + if agent.Scope != SessionScopeSystem && agent.Scope != SessionScopeDeployment { + return nil, fmt.Errorf("scope must be %q or %q", SessionScopeSystem, SessionScopeDeployment) + } + if agent.Scope == SessionScopeDeployment && agent.Deployment == "" { + return nil, fmt.Errorf("a deployment-scoped agent must name its deployment") + } + agent.Instructions = strings.TrimSpace(body) + if agent.Instructions == "" { + return nil, fmt.Errorf("the agent has no instructions") + } + return agent, nil +} + +// AgentStore reads agent definitions from a flat directory of markdown files, +// one agent per file, named by the file's basename. +type AgentStore struct { + dir string +} + +func NewAgentStore(deploymentsPath string) *AgentStore { + return &AgentStore{dir: filepath.Join(deploymentsPath, ".flatrun", "agents")} +} + +// Dir is the directory agent definition files live in. +func (st *AgentStore) Dir() string { return st.dir } + +var ErrAgentNotFound = fmt.Errorf("agent not found") + +// Get loads one agent by name. +func (st *AgentStore) Get(name string) (*Agent, error) { + if !agentNamePattern.MatchString(name) { + return nil, ErrAgentNotFound + } + content, err := os.ReadFile(filepath.Join(st.dir, name+".md")) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrAgentNotFound + } + return nil, err + } + return ParseAgent(name, string(content)) +} + +// Raw returns the file content of one agent, for editing. +func (st *AgentStore) Raw(name string) (string, error) { + if !agentNamePattern.MatchString(name) { + return "", ErrAgentNotFound + } + content, err := os.ReadFile(filepath.Join(st.dir, name+".md")) + if err != nil { + if os.IsNotExist(err) { + return "", ErrAgentNotFound + } + return "", err + } + return string(content), nil +} + +// Write validates and stores an agent definition. Invalid definitions are +// rejected rather than written, so the directory only ever holds runnable +// agents. +func (st *AgentStore) Write(name, content string) (*Agent, error) { + if !agentNamePattern.MatchString(name) { + return nil, fmt.Errorf("invalid agent name %q: use letters, digits, dots, dashes, underscores", name) + } + agent, err := ParseAgent(name, content) + if err != nil { + return nil, err + } + if err := os.MkdirAll(st.dir, 0700); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(st.dir, name+".md"), []byte(content), 0600); err != nil { + return nil, err + } + return agent, nil +} + +// Delete removes an agent definition. +func (st *AgentStore) Delete(name string) error { + if !agentNamePattern.MatchString(name) { + return ErrAgentNotFound + } + err := os.Remove(filepath.Join(st.dir, name+".md")) + if os.IsNotExist(err) { + return ErrAgentNotFound + } + return err +} + +// List returns every valid agent, sorted by name. A file that fails to parse +// is skipped rather than breaking the listing. +func (st *AgentStore) List() ([]*Agent, error) { + entries, err := os.ReadDir(st.dir) + if err != nil { + if os.IsNotExist(err) { + return []*Agent{}, nil + } + return nil, err + } + agents := []*Agent{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + name := strings.TrimSuffix(e.Name(), ".md") + agent, err := st.Get(name) + if err != nil { + continue + } + agents = append(agents, agent) + } + sort.Slice(agents, func(i, j int) bool { return agents[i].Name < agents[j].Name }) + return agents, nil +} diff --git a/internal/ai/agent_test.go b/internal/ai/agent_test.go new file mode 100644 index 0000000..1d70679 --- /dev/null +++ b/internal/ai/agent_test.go @@ -0,0 +1,89 @@ +package ai + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseAgentWithFrontmatter(t *testing.T) { + rt, err := ParseAgent("tidy-logs", `--- +description: Trim old logs +scope: deployment +deployment: myapp +--- +Check the logs directory and report anything unusual.`) + if err != nil { + t.Fatal(err) + } + if rt.Name != "tidy-logs" || rt.Description != "Trim old logs" || + rt.Scope != SessionScopeDeployment || rt.Deployment != "myapp" { + t.Errorf("unexpected agent: %+v", rt) + } + if rt.Instructions != "Check the logs directory and report anything unusual." { + t.Errorf("instructions = %q", rt.Instructions) + } +} + +func TestParseAgentBareMarkdown(t *testing.T) { + rt, err := ParseAgent("hello", "Say hello to the operator.") + if err != nil { + t.Fatal(err) + } + if rt.Scope != SessionScopeSystem { + t.Errorf("a bare file should default to system scope, got %q", rt.Scope) + } + if rt.Instructions != "Say hello to the operator." { + t.Errorf("instructions = %q", rt.Instructions) + } +} + +func TestParseAgentRejectsBadDefinitions(t *testing.T) { + cases := map[string]string{ + "bad scope": "---\nscope: galaxy\n---\nDo things.", + "deployment missing": "---\nscope: deployment\n---\nDo things.", + "no instructions": "---\ndescription: empty\n---\n", + "frontmatter not yaml": "---\n: [\n---\nDo things.", + "only whitespace body": "---\nscope: system\n---\n \n", + } + for label, content := range cases { + if _, err := ParseAgent("x", content); err == nil { + t.Errorf("%s: expected an error", label) + } + } +} + +func TestAgentStoreListSkipsInvalid(t *testing.T) { + dir := t.TempDir() + st := NewAgentStore(dir) + if err := os.MkdirAll(st.Dir(), 0700); err != nil { + t.Fatal(err) + } + writeAgent := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(st.Dir(), name), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + writeAgent("beta.md", "Report disk usage.") + writeAgent("alpha.md", "---\ndescription: first\n---\nSay hi.") + writeAgent("broken.md", "---\nscope: nope\n---\nX.") + writeAgent("notes.txt", "not a agent") + + agents, err := st.List() + if err != nil { + t.Fatal(err) + } + if len(agents) != 2 || agents[0].Name != "alpha" || agents[1].Name != "beta" { + t.Errorf("unexpected listing: %+v", agents) + } +} + +func TestAgentStoreGetRefusesTraversal(t *testing.T) { + st := NewAgentStore(t.TempDir()) + for _, name := range []string{"../escape", "a/b", ".hidden", ""} { + if _, err := st.Get(name); err != ErrAgentNotFound { + t.Errorf("name %q: expected ErrAgentNotFound, got %v", name, err) + } + } +} diff --git a/internal/ai/session.go b/internal/ai/session.go index 8c7daa9..f13a482 100644 --- a/internal/ai/session.go +++ b/internal/ai/session.go @@ -32,18 +32,21 @@ type SessionActor struct { // transcript (including tool calls and results) plus the derived state // the UI needs. Stored as a flat JSON file, true to FlatRun. type Session struct { - ID string `json:"id"` - Scope string `json:"scope"` - Deployment string `json:"deployment,omitempty"` - AutoRun bool `json:"auto_run"` - Status string `json:"status"` - Model string `json:"model,omitempty"` - CreatedBy SessionActor `json:"created_by"` - Messages []Message `json:"messages"` - Pending []ToolCall `json:"pending,omitempty"` - Suggested []SuggestedAction `json:"suggested_actions"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Scope string `json:"scope"` + Deployment string `json:"deployment,omitempty"` + // Agent names the agent definition this session is a run of, when it was + // started from one rather than typed by an operator. + Agent string `json:"agent,omitempty"` + AutoRun bool `json:"auto_run"` + Status string `json:"status"` + Model string `json:"model,omitempty"` + CreatedBy SessionActor `json:"created_by"` + Messages []Message `json:"messages"` + Pending []ToolCall `json:"pending,omitempty"` + Suggested []SuggestedAction `json:"suggested_actions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func NewSession(scope, deployment string, autoRun bool, actor SessionActor, systemPrompt string) *Session { @@ -188,6 +191,7 @@ type SessionSummary struct { ID string `json:"id"` Scope string `json:"scope"` Deployment string `json:"deployment,omitempty"` + Agent string `json:"agent,omitempty"` Status string `json:"status"` Title string `json:"title"` CreatedBy SessionActor `json:"created_by"` @@ -231,6 +235,7 @@ func (s *Session) Summary() SessionSummary { ID: s.ID, Scope: s.Scope, Deployment: s.Deployment, + Agent: s.Agent, Status: s.Status, Title: s.Title(), CreatedBy: s.CreatedBy, diff --git a/internal/api/agent_handlers.go b/internal/api/agent_handlers.go new file mode 100644 index 0000000..32b14fe --- /dev/null +++ b/internal/api/agent_handlers.go @@ -0,0 +1,110 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/flatrun/agent/internal/ai" + "github.com/flatrun/agent/internal/auth" + "github.com/gin-gonic/gin" +) + +// listAgents returns every agent defined as a markdown file under the agents +// directory. +func (s *Server) listAgents(c *gin.Context) { + agents, err := s.aiAgents.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"agents": agents, "dir": s.aiAgents.Dir()}) +} + +// getAgent returns one agent's parsed metadata and raw file content, for the +// panel's editor. +func (s *Server) getAgent(c *gin.Context) { + name := c.Param("name") + agent, err := s.aiAgents.Get(name) + if err == ai.ErrAgentNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + content, err := s.aiAgents.Raw(name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"agent": agent, "content": content}) +} + +// putAgent validates and writes an agent definition from the panel's editor. +func (s *Server) putAgent(c *gin.Context) { + var req struct { + Content string `json:"content"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + agent, err := s.aiAgents.Write(c.Param("name"), req.Content) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"agent": agent}) +} + +// deleteAgent removes an agent definition. +func (s *Server) deleteAgent(c *gin.Context) { + if err := s.aiAgents.Delete(c.Param("name")); err != nil { + if err == ai.ErrAgentNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Agent deleted", "name": c.Param("name")}) +} + +// runAgent executes an agent by seeding an AI session with its instructions. +// The run inherits every session guarantee the runtime provides: +// permission-gated tools, protected mode, secret redaction, and the pause for +// per-call approval before any state-changing tool. +func (s *Server) runAgent(c *gin.Context) { + if !s.aiRequireEnabled(c) { + return + } + agent, err := s.aiAgents.Get(c.Param("name")) + if err == ai.ErrAgentNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if agent.Scope == ai.SessionScopeDeployment && !s.requireDeploymentAccess(c, agent.Deployment, auth.AccessLevelRead) { + return + } + + prompt := ai.BuildSessionPrompt(agent.Scope, agent.Deployment, s.config.AI.DocsURL) + sess := ai.NewSession(agent.Scope, agent.Deployment, true, sessionActorFrom(c), prompt) + sess.Agent = agent.Name + display := fmt.Sprintf("Run the %q agent", agent.Name) + sess.AddUserMessage(s.redactSessionInput(sess, agent.Instructions), display, false) + + if err := s.advanceSession(c, sess); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if err := s.aiSessions.Save(sess); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + s.sessionResponse(c, sess) +} diff --git a/internal/api/agent_handlers_test.go b/internal/api/agent_handlers_test.go new file mode 100644 index 0000000..2d97551 --- /dev/null +++ b/internal/api/agent_handlers_test.go @@ -0,0 +1,202 @@ +package api + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flatrun/agent/internal/ai" + "github.com/flatrun/agent/pkg/models" +) + +func writeAgentFile(t *testing.T, tmpDir, name, content string) { + t.Helper() + dir := filepath.Join(tmpDir, ".flatrun", "agents") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } +} + +func TestListAgents(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + s.aiAgents = ai.NewAgentStore(tmpDir) + writeAgentFile(t, tmpDir, "disk-report.md", "---\ndescription: Report disk usage\n---\nSummarize disk usage.") + + resp, parsed := doJSON(t, http.MethodGet, ts.URL+"/api/ai/agents", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body %v", resp.StatusCode, parsed) + } + agents := parsed["agents"].([]interface{}) + if len(agents) != 1 { + t.Fatalf("agents = %v", agents) + } + first := agents[0].(map[string]interface{}) + if first["name"] != "disk-report" || first["description"] != "Report disk usage" { + t.Errorf("unexpected agent: %v", first) + } +} + +func TestRunAgentExecutesAsSession(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + s.aiAgents = ai.NewAgentStore(tmpDir) + writeAgentFile(t, tmpDir, "hello.md", "Say hello and stop.") + + s.aiProvider = &scriptedProvider{responses: []*ai.Response{{Content: "Hello.", Model: "scripted"}}} + + resp, parsed := doJSON(t, http.MethodPost, ts.URL+"/api/ai/agents/hello/run", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body %v", resp.StatusCode, parsed) + } + if parsed["status"] != "ready" { + t.Errorf("status = %v", parsed["status"]) + } + // The transcript shows the short label, not the instruction body. + messages := parsed["messages"].([]interface{}) + first := messages[0].(map[string]interface{}) + if first["content"] != `Run the "hello" agent` { + t.Errorf("displayed turn = %v", first["content"]) + } + // The run records which agent it belongs to, so an agent's run history + // is just its sessions. + if parsed["agent"] != "hello" { + t.Errorf("agent = %v, want hello", parsed["agent"]) + } + // The run is a persisted session, resumable like any other. + saved, err := s.aiSessions.Get(parsed["id"].(string)) + if err != nil { + t.Fatalf("run not persisted as a session: %v", err) + } + if saved.Agent != "hello" { + t.Errorf("persisted agent = %q, want hello", saved.Agent) + } +} + +func TestRunAgentPausesForMutatingTools(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + s.aiAgents = ai.NewAgentStore(tmpDir) + createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) + writeAgentFile(t, tmpDir, "fix-config.md", + "---\nscope: deployment\ndeployment: myapp\n---\nEnsure conf/app.conf sets key to value.") + + s.aiProvider = &scriptedProvider{responses: []*ai.Response{ + {ToolCalls: []ai.ToolCall{{ID: "c1", Name: "write_deployment_file", + Arguments: `{"path":"conf/app.conf","content":"key = value\n"}`}}, Model: "scripted"}, + {Content: "Done.", Model: "scripted"}, + }} + + resp, parsed := doJSON(t, http.MethodPost, ts.URL+"/api/ai/agents/fix-config/run", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body %v", resp.StatusCode, parsed) + } + // A agent run must not bypass the approval gate on state changes. + if parsed["status"] != "awaiting_approval" { + t.Fatalf("status = %v, want awaiting_approval", parsed["status"]) + } + if _, err := os.Stat(filepath.Join(tmpDir, "myapp", "conf", "app.conf")); !os.IsNotExist(err) { + t.Fatal("the file must not be written before approval") + } +} + +func TestRunAgentRedactsSecretsFromInstructions(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + s.aiAgents = ai.NewAgentStore(tmpDir) + createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) + envContent := "DB_PASSWORD=hunter2secret\n" + if err := os.WriteFile(filepath.Join(tmpDir, "myapp", ".env.flatrun"), []byte(envContent), 0600); err != nil { + t.Fatal(err) + } + writeAgentFile(t, tmpDir, "leaky.md", + "---\nscope: deployment\ndeployment: myapp\n---\nThe password is hunter2secret; check the database.") + + stub := &scriptedProvider{responses: []*ai.Response{{Content: "Checked.", Model: "scripted"}}} + s.aiProvider = stub + + if resp, parsed := doJSON(t, http.MethodPost, ts.URL+"/api/ai/agents/leaky/run", nil); resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body %v", resp.StatusCode, parsed) + } + var sent strings.Builder + for _, m := range stub.lastRequestMessages() { + sent.WriteString(m.Content) + } + if strings.Contains(sent.String(), "hunter2secret") { + t.Error("a secret in the agent file reached the provider") + } +} + +func TestAgentEditorRoundTrip(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + s.aiAgents = ai.NewAgentStore(tmpDir) + + content := "---\ndescription: Report disk usage\n---\nSummarize disk usage." + resp, parsed := doJSON(t, http.MethodPut, ts.URL+"/api/ai/agents/disk-report", + map[string]interface{}{"content": content}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("put status = %d, body %v", resp.StatusCode, parsed) + } + + resp, parsed = doJSON(t, http.MethodGet, ts.URL+"/api/ai/agents/disk-report", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("get status = %d, body %v", resp.StatusCode, parsed) + } + if parsed["content"] != content { + t.Errorf("content = %v", parsed["content"]) + } + + // An invalid definition is rejected, not written. + resp, _ = doJSON(t, http.MethodPut, ts.URL+"/api/ai/agents/broken", + map[string]interface{}{"content": "---\nscope: galaxy\n---\nX."}) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("invalid definition status = %d, want 400", resp.StatusCode) + } + + resp, _ = doJSON(t, http.MethodDelete, ts.URL+"/api/ai/agents/disk-report", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("delete status = %d", resp.StatusCode) + } + if _, err := s.aiAgents.Get("disk-report"); err != ai.ErrAgentNotFound { + t.Errorf("agent still present after delete: %v", err) + } +} + +func TestWriteAgentFileToolCreatesRunnableAgent(t *testing.T) { + s, tmpDir, _ := setupPlanTestServer(t) + s.aiAgents = ai.NewAgentStore(tmpDir) + + tool := s.aiToolRegistry()["write_agent_file"] + out, err := tool.Run(s, toolCtx(nil), "", map[string]interface{}{ + "name": "hello", + "content": "Say hello to the operator.", + }) + if err != nil { + t.Fatalf("write failed: %v", err) + } + if !strings.Contains(out, "hello") { + t.Errorf("unexpected output %q", out) + } + if _, err := s.aiAgents.Get("hello"); err != nil { + t.Errorf("agent not runnable after tool write: %v", err) + } + + // An invalid definition must be refused, not written. + if _, err := tool.Run(s, toolCtx(nil), "", map[string]interface{}{ + "name": "bad", "content": "---\nscope: deployment\n---\nX.", + }); err == nil { + t.Error("an invalid definition should be rejected") + } +} + +func TestRunAgentNotFound(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + s.aiAgents = ai.NewAgentStore(tmpDir) + s.aiProvider = &scriptedProvider{} + + resp, _ := doJSON(t, http.MethodPost, ts.URL+"/api/ai/agents/ghost/run", nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404", resp.StatusCode) + } +} diff --git a/internal/api/ai_session_handlers.go b/internal/api/ai_session_handlers.go index 46c45dd..7fec6f3 100644 --- a/internal/api/ai_session_handlers.go +++ b/internal/api/ai_session_handlers.go @@ -189,6 +189,7 @@ func (s *Server) sessionResponse(c *gin.Context, sess *ai.Session) { "id": sess.ID, "scope": sess.Scope, "deployment": sess.Deployment, + "agent": sess.Agent, "auto_run": sess.AutoRun, "status": sess.Status, "model": sess.Model, diff --git a/internal/api/ai_tools.go b/internal/api/ai_tools.go index e47a359..5037ad0 100644 --- a/internal/api/ai_tools.go +++ b/internal/api/ai_tools.go @@ -411,6 +411,68 @@ func (s *Server) aiToolRegistry() map[string]aiTool { return truncateToolOutput(fmt.Sprintf("Ran %s on deployment %s.\n%s", action, name, output)), nil }, }, + "list_agents": { + Spec: ai.Tool{ + Name: "list_agents", + Description: "List the agents defined on this server: name, description, and scope. Read-only.", + Parameters: objSchema(map[string]interface{}{}), + }, + Run: func(s *Server, _ *gin.Context, _ string, _ map[string]interface{}) (string, error) { + agents, err := s.aiAgents.List() + if err != nil { + return "", err + } + if len(agents) == 0 { + return "No agents are defined yet.", nil + } + var b strings.Builder + for _, a := range agents { + scope := a.Scope + if a.Deployment != "" { + scope += ":" + a.Deployment + } + fmt.Fprintf(&b, "- %s (%s): %s\n", a.Name, scope, a.Description) + } + return b.String(), nil + }, + }, + "read_agent_file": { + Spec: ai.Tool{ + Name: "read_agent_file", + Description: "Read an agent's definition file: its frontmatter and instructions. Read-only.", + Parameters: objSchema(map[string]interface{}{ + "name": strProp("The agent's name."), + }, "name"), + }, + Run: func(s *Server, _ *gin.Context, _ string, args map[string]interface{}) (string, error) { + content, err := s.aiAgents.Raw(argString(args, "name")) + if err != nil { + return "", err + } + return truncateToolOutput(content), nil + }, + }, + "write_agent_file": { + Mutates: true, + Spec: ai.Tool{ + Name: "write_agent_file", + Description: "Create or update an agent: a markdown file with optional YAML frontmatter (description, scope, deployment) and a body of instructions. The definition is validated before it is written. Requires settings write access.", + Parameters: objSchema(map[string]interface{}{ + "name": strProp("The agent's name: letters, digits, dots, dashes, underscores."), + "content": strProp("The full file content: optional frontmatter plus the instructions."), + }, "name", "content"), + }, + Run: func(s *Server, c *gin.Context, _ string, args map[string]interface{}) (string, error) { + if err := s.toolAllowedGlobalWrite(c); err != nil { + return "", err + } + agent, err := s.aiAgents.Write(argString(args, "name"), argString(args, "content")) + if err != nil { + return "", err + } + return fmt.Sprintf("Wrote agent %q (%s scope).", agent.Name, agent.Scope), nil + }, + }, "get_security_events": { Spec: ai.Tool{ Name: "get_security_events", diff --git a/internal/api/server.go b/internal/api/server.go index 37b7ce6..73b33d6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -101,6 +101,7 @@ type Server struct { planStore *plan.Store aiProvider ai.Provider aiSessions *ai.SessionStore + aiAgents *ai.AgentStore mcpHandler http.Handler jobs *jobRegistry @@ -343,6 +344,7 @@ func New(cfg *config.Config, configPath string) *Server { setupHandlers: setup.NewHandlers(setupManager, authManager), planStore: plan.NewStore(cfg.DeploymentsPath), aiSessions: ai.NewSessionStore(cfg.DeploymentsPath), + aiAgents: ai.NewAgentStore(cfg.DeploymentsPath), jobs: newJobRegistry(), } s.runDeploymentAction = s.defaultRunDeploymentAction @@ -511,6 +513,14 @@ func (s *Server) setupRoutes() { // handler itself refuses calls while mcp.enabled is off. protected.Any("/mcp", s.mcpHTTP) + // Agents defined as flat markdown files, executed by the runtime + // as AI sessions through the same tools and approval gates. + protected.GET("/ai/agents", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.listAgents) + protected.GET("/ai/agents/:name", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.getAgent) + protected.PUT("/ai/agents/:name", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.putAgent) + protected.DELETE("/ai/agents/:name", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.deleteAgent) + protected.POST("/ai/agents/:name/run", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.runAgent) + // Interactive AI sessions (agentic tool loop) protected.POST("/ai/sessions", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.createAISession) protected.GET("/ai/sessions", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.listAISessions)