Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 `<deployments>/.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.
162 changes: 162 additions & 0 deletions internal/ai/agent.go
Original file line number Diff line number Diff line change
@@ -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
}
89 changes: 89 additions & 0 deletions internal/ai/agent_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading