ollie/doc/architecture.md

17 KiB

ollie Architecture

Philosophy

ollie's design philosophy is Plan 9 / Acme / Unix: a small substrate that integrates with the surrounding system rather than trying to do everything itself. The Go runtime (agent.Agent) defines what an agent is — an event loop, a backend connection, a message history, and three built-in primitives (shell, tool registry, skill registry). Everything else — orchestration, scheduling, workflows, UIs — is pushed out to external tools, scripts, and frontends.

The key difference from the Emacs model: Emacs extensibility means building capabilities on top of its Lisp interpreter and text primitives. Ollie's extensibility means not building capabilities into the core at all — instead, the core provides integration surfaces (9P filesystem, agent loop) and lets the surrounding system handle everything else. This is the Acme approach: a lightweight framework that delegates to external programs via a shared filesystem namespace. The primary integration philosophy is Plan 9's "everything is a file." olliesrv exposes agent state and behaviors as files in a 9P namespace. Any program that can read and write files can drive an agent: shell scripts, editors, web apps, cron, containers. All clients communicate via 9P exclusively. Desktop notifications use D-Bus directly (org.freedesktop.Notifications) for elevation prompts only — this is the last remaining D-Bus dependency. Design principles:

  • Small extensible core. The agent runtime is minimal; capabilities come from composing external scripts.
  • Zero built-in tools. No tools are compiled into the Go binary. Every tool — including shell and reasoning_think — is an external script loaded dynamically via the 9P filesystem. The core is pure coordination: stream LLM → dispatch tool calls → loop.
  • **One integration path: 9P filesystem. Streaming via blocking reads. No polling.
  • 9P namespace declared via EDSL. The entire filesystem is a single recursive FsNodeDecl tree in fs/spec.go, validated and built by BuildTree() in fs/builder.go. Adding a file means adding a Leaf() call — no manual stat/readdir/write wiring.
  • No framework lock-in. Frontends are decoupled via whichever interface they prefer; the core doesn't know or care.

Repository Structure

Single Go module (ollie) with one Git submodule (kde/) for the KDE frontend:

ollie/
├── agent/           Agent loop, history, hooks, prompt resolution, commands
├── backend/         LLM providers (Anthropic, OpenAI, Ollama, Gemini, Copilot, CodeWhisperer)
├── toolsrv/         Tool server: sandboxed execution, tool registry, skill management
├── session/         Session lifecycle (config, creation, persistence)
├── fs/              9P filesystem: EDSL spec + handlers (flat package)
│   ├── spec.go          Namespace declaration (single source of truth)
│   ├── fsnode.go        FsNodeDecl type + Dir/Leaf/TemplateDir constructors
│   ├── builder.go       BuildTree — spec -> *Tree wiring
│   ├── rootfiles.go     Root-level handlers (backends, models, eventwait, complete, generate, route)
│   ├── sessionfiles.go  Session-level handlers (env, ctl, plan, agent/, ...)
│   ├── agentfiles.go    Agent-level handlers (prompt, chat, state, cfg, ...)
│   ├── elevatefiles.go  Elevation handlers (policy, pending)
│   ├── procfiles.go     Process handlers
│   ├── lifecycle.go     Session create/kill/rename/shutdown + event ring
│   ├── newroot.go       NewRoot — tree construction + persistence restore
│   ├── persist.go       Session persistence to disk
│   ├── types.go         Session/AgentLog types
│   ├── tree.go          9P *Tree (from fs package)
│   ├── fs.go            9P File/FileConfig implementations
│   └── format.go        Event formatting helpers
├── detach/          Background process management (ring buffer, signal)
├── elevate/         Elevation broker (privilege escalation daemon)
├── sandbox/         Landlock sandbox config YAML
├── env/             Environment variable loading
├── log/             Structured logging
├── paths/           XDG path resolution
├── mount/           9P FUSE mount client
├── cmd/             Binaries:
│   ├── olliesrv/        9P server
│   ├── ollie-9p/        9P client
│   └── ollie-remote/    Remote execution server
├── kde/             KDE integration (submodule) — standalone GUI, Kate plugin, KRunner
├── data/agents/     Agent config JSONs (default, coding, orchestrator, worker, ...)
├── data/prompts/    Prompt templates (markdown)
├── data/tools/      Tool scripts + .meta sidecar files
├── data/skills/     Domain knowledge modules (markdown)
├── data/scripts/    Helper scripts (ollie-remount, ollie-watchdog)
├── data/services/   Systemd/xdg-autostart service files
├── prompts/         Embedded prompt templates (compiled into binary)
└── doc/             Documentation

System Overview

flowchart TB
    subgraph Frontends["Frontends"]
        ACME["acme (Plan 9)"]
        ELLIE["ellie (Emacs)"]
        KDE["KDE GUI / Kate / KRunner"]
        SH["o (terminal CLI / tmux TUI)"]
        HTTP["curl / scripts"]
    end
    subgraph Integration["Integration Layer"]
        direction LR
        P9["9P Filesystem\n(ollie-9p client)"]
    end
    subgraph Server["olliesrv"]
        direction TB
        NS["9P Namespace\nEDSL-declared in fs/spec.go"]
    end
    subgraph Core["Agent Engine (per session)"]
        LOOP["Agent Loop\n(agent/loop.go)"]
        SESS["Session State\n(agent/history.go)"]
        TRV["toolsrv.Server\n· dynamic tool dispatch\n· landlock sandboxed execution\n· remote execution via SSH"]
    end
    subgraph Backends["LLM Backends"]
        OLLAMA["Ollama"]
        OPENAI["OpenAI / OpenRouter"]
        ANTHROPIC["Anthropic"]
        GEMINI["Gemini"]
        CW["CodeWhisperer"]
    end
    SH --> P9
    ACME --> P9
    ELLIE --> P9
    KDE --> P9
    HTTP --> P9
    P9 --> NS
    NS --> LOOP
    LOOP <--> SESS
    LOOP --> TRV
    LOOP <--> Backends
    TRV --> Tools["tool scripts\n(OLLIE_TOOLS_PATH)\n· shell, reasoning_think\n· file_read, file_write\n· lsp_*, memory_*\n· gui_*, subagent_*"]
    TRV --> Remote["ollie-remote (SSH)\npure 9P RPC\nno embedded tools"]

Core Library

The core is a single Go module (ollie) with no binary. Binaries live in cmd/.

Package Layout

Package Purpose
agent/ Agent struct, loop, history, compaction, hooks, commands, prompt resolution, state
backend/ Backend interface + LLM providers (Anthropic, OpenAI, Ollama, Gemini, Copilot, CodeWhisperer)
toolsrv/ Tool server: dynamic tool dispatch, sandboxed execution, result tiering, remote execution (ollie-remote)
session/ Session lifecycle, config, persistence
detach/ Background process management (ring buffer, signal)
elevate/ Elevation broker (privilege escalation daemon, policy)
sandbox/ Landrun sandbox configuration and command wrapping
env/ Session environment helpers
log/ Structured logging
paths/ XDG path resolution

Key Types

agent.Agent — the concrete struct frontends drive:

type Agent struct {
    // unexported fields — no public interface
}
func New(cfg AgentConfig) *Agent
func (a *Agent) Submit(ctx context.Context, input string)
func (a *Agent) Interrupt(cause error) bool
func (a *Agent) Queue(prompt string)
func (a *Agent) State() string
func (a *Agent) WaitChange(ctx context.Context, field, current string) (string, bool)
func (a *Agent) Close()
// ... model/backend info, CWD, usage, cost, etc.

backend.Backend — LLM provider contract:

type Backend interface {
    ChatStream(ctx context.Context, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error)
    Name() string
    Model() string
    SetModel(model string)
    ContextLength(ctx context.Context) int
    Models(ctx context.Context) []string
}

toolsrv.Runner — minimal tool execution interface:

type Runner interface {
    ListTools() ([]ToolInfo, error)
    CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error)
}

toolsrv.Server — the concrete tool server (implements Runner):

type Server struct { /* ... */ }

Agent Loop (agent/loop.go)

The loop is the heart of the system. On each turn:

  1. Stream — call backend.ChatStream() with messages + tool definitions
  2. Dispatch — if the model returns tool calls, execute them via toolsrv.Server
  3. Update — append assistant message + tool results to session history
  4. Repeat — loop until the model produces a final text response (no tool calls) Safety mechanisms:
  • Consecutive error limits: soft nudge at 5, hard abort at 10
  • Replan gate: after 8 rounds without a PLAN: block, inject a replan nudge
  • Stall detection: 5 rounds with unchanged LastAction triggers a nudge
  • Max steps: configurable per-agent; soft exit when reached
  • Transient retry: up to 3 retries with exponential backoff for rate limits and 5xx
  • Context overflow: triggers automatic compaction and retry

Session & Context Management (agent/history.go)

History owns the message history as a flat []backend.Message slice. Key behaviors:

  • TaskState: structured JSON overlay (objective, plan_step, constraints, last_action, next_decision) injected at the top of every turn
  • Compaction: three-zone strategy when context reaches 50% of model limit:
    • Cold zone: structured task state summary
    • Warm zone: one-line decision index (10 messages)
    • Hot zone: last 8 messages verbatim
  • Result tiers: tool results classified as Hot (verbatim), Warm (summarized on compaction), or Cold (immediately collapsed)
  • Persistence: sessions saved as JSON to {sessionsDir}/{id}.json

Hooks (agent/hooks.go)

Lifecycle callbacks executed as shell commands with JSON payload on stdin:

Hook When Exit codes
agentSpawn Session creation 0=ok, 2=block
preTurn Before each turn (deprecated) 0=inject stdout, 2=block
postTurn After each turn 0=ok
preTool Before each tool call 0=ok, 2=block execution
postTool After each tool call 0=append, 2=replace result
preCompact Before compaction 0=ok
postCompact After compaction 0=ok
turnError On backend error 0=handled (skip retries)

Prompt Resolution (agent/prompt_resolver.go)

Agent configs declare prompts as a JSON array of shell commands. Each command is executed with $OLLIE and $PWD available; stdout is concatenated to form the system prompt. This enables dynamic, composable prompts assembled from template fragments.

9P Server (cmd/olliesrv/)

olliesrv is the central daemon. It implements a 9P2000 file server that exposes the entire agent namespace:

Namespace Layout

/                          ← root (owned by system user)
├── backends               backends list
├── help                   help text
├── models                 model list (cached)
├── agents                 agent config list
├── tools                 global tool catalog (all discoverable tools on disk)
├── ctl                    root control (invalidate, kill)
├── eventwait              global event stream (blocking read)
├── complete               request-response: code completion
├── generate               request-response: one-shot LLM generation
├── route                  request-response: model routing
├── elevate/
│   ├── policy             global elevation policy
│   └── pending/{id}       pending elevation requests (approve/deny/persist)
└── session/
    ├── new                write key=value to create session
    ├── idx                session index (one line per agent)
    ├── {name}/
    │   ├── env            session environment variables
    │   ├── ctl            session control (kill, save, invalidate)
    │   ├── plan           session-scoped markdown checklist
    │   ├── id             immutable session UUID
    │   ├── name           mutable session name (write to rename)
    │   ├── elevate        per-session elevation policy
    │   └── agent/
    │       ├── new        write config to create agent (rdwr)
    │       └── {aid}/
    │           ├── prompt        submit a prompt
    │           ├── prompt.prev   last submitted prompt
    │           ├── fifo.in       queue a prompt
    │           ├── fifo.out      pop queued prompt
    │           ├── chat          streaming chat log (blocking read)
    │           ├── log           last 64KB of chat (non-blocking)
    │           ├── state         current state (idle/thinking/calling)
    │           ├── statewait     blocking read until state changes
    │           ├── cfg           agent config (key=value)
    │           ├── ctl           agent control (stop, compact, ...)
    │           ├── cwd           working directory
    │           ├── id            immutable agent UUID
    │           ├── name          mutable agent name
    │           ├── offset        byte offset after last user prompt
    │           ├── usage         token usage stats
    │           ├── cost          estimated cost
    │           ├── ctxsz         context size
    │           ├── models        available models
    │           ├── systemprompt  rendered system prompt
    │           ├── context       rendered context window
    │           ├── tail          exec helper for tailing chat
    │           ├── tools         tool management (read = list loaded, write name = load)
    │           └── proc/{pid}    detached process output

9P Interaction Model

All agent interaction uses the ollie-9p client, which auto-discovers the server via $NAMESPACE and identifies the caller via $OLLIE_UNAME. The 9P filesystem exposes session state, control, tool management, and inter-agent communication as files:

  • Sessions are directories under session/
  • Session creation is a two-step process: session/new (name only) then session/{name}/agent/new (full config)
  • Sessions and agents have immutable UUIDs (id file) and mutable human-readable names (name file); writing to name renames the entry
  • Tool loading is a file write — writing a tool name to agent/{id}/tools loads it for the agent; reading it lists currently loaded tools
  • Global tool catalog at /tools lists all discoverable tools on disk
  • Prompts, state, chat history, and config are readable/writable files
  • Streaming via blocking reads: chat, statewait, eventwait block the 9P read until data arrives
  • Permission enforcement uses 9P user principals
  • The entire namespace is declared as a single EDSL tree in fs/spec.go — see doc/edsl.md

Permission Model (fs/spec.go)

Permissions are declared inline in the EDSL spec via mode and GID() options. There is no separate permission registry — the spec is the source of truth:

  • prompt is mode 0666 — group+other can write, owner (the agent) cannot
  • chat is mode 0444 — read-only for everyone
  • ctl is mode 0666 — anyone can send control commands
  • plan is mode 0666 — readable by peer agents
  • eventwait is 0444 — blocking read for global event stream
  • session/new is 0666 with Request handler — anyone can create sessions
  • Ownership inherits: empty UID/GID values inherit from parent node, all the way up to root

Session Management (fs/)

The *fs.Tree IS the session collection. Package functions manage lifecycle:

  • NewRoot(cfg) — create the root tree from the EDSL spec (fs/spec.go → BuildTree → wired *Tree)
  • Lookup(tree, id) — find a session by ID
  • All(tree) — list all sessions
  • CreateFromRoot(tree, args) — create a new session (two-step: session name, then agent creation)
  • KillFromRoot(tree, id) — kill a session
  • RenameFromRoot(tree, old, new) — rename a session
  • Shutdown(tree) — clean shutdown (interrupt all, wait for idle, persist, close)

Event streaming uses a global ring buffer (eventRing) with a global /eventwait file. Events carry prefixes (S for session, A for agent) with structured descriptions (new, kill, rename). Frontends block on /eventwait and receive delta events without polling.