ollie/doc/architecture.md

16 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 delegated to external programs via a shared 9P filesystem 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 bypass 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. The KDE frontend is maintained in the kde/ directory and built as part of the same repository.

ollie/
├── cmd/
│   ├── olliesrv/        9P server (sessions, agents, backends)
│   │   └── internal/    agent/, backend/, bypass/, fs/, session/, prompts/, toolclient/
│   ├── toolsrv/         9P tool execution server (separate process)
│   │   └── internal/    fs/, exec/, registry/, sandbox/
│   ├── ollie-9p/        9P client CLI
├── toolsrv/         Client library (9P client for toolsrv)
├── virtfs/          Filesystem declaration EDSL (generic, reusable)
├── lib9p/           9P protocol library + native client
├── env/             Environment variable loading
├── log/             Structured logging
├── paths/           XDG path resolution
├── format/          Chat log formatting constants
├── kde/             KDE integration — GUI, Kate, KIO, KRunner, Dolphin
├── data/agents/     Agent config JSONs (default, coding, orchestrator, worker, ...)
├── data/prompts/    Prompt templates (markdown)
├── data/tools/      Tool definitions (.meta files + executables)
├── data/skills/     Domain knowledge modules (markdown)
├── data/scripts/    Helper scripts (ollie-remount, o)
├── 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)"]
    end
    subgraph ToolSrv["toolsrv (child process)"]
        TRV["9P Server\n· tool registry + definitions\n· dynamic tool dispatch\n· landlock sandboxed execution\n· idle timeout + Pdeathsig lifecycle"]
    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

Core Library

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

Package Layout

Package Purpose
cmd/olliesrv/internal/agent/ Agent struct, loop, history, compaction, hooks, commands, prompt resolution, state
cmd/olliesrv/internal/backend/ Backend interface + LLM providers (Anthropic, OpenAI, Ollama, Gemini, Copilot, CodeWhisperer)
cmd/olliesrv/internal/fs/ 9P filesystem: EDSL spec + handlers, ctl dispatch, session nodes
cmd/olliesrv/internal/session/ Session lifecycle, persistence, tool server spawning
cmd/olliesrv/internal/bypass/ Bypass broker (privilege escalation daemon, policy)
cmd/olliesrv/internal/toolclient/ Spawning and managing toolsrv child processes
cmd/toolsrv/internal/fs/ toolsrv filesystem spec, state, process management
cmd/toolsrv/internal/exec/ Sandboxed tool execution (landrun, bypass)
cmd/toolsrv/internal/registry/ Session-scoped tool registry
cmd/toolsrv/internal/sandbox/ Landrun sandbox configuration and command wrapping
toolsrv/ 9P client library for connecting to toolsrv
virtfs/ Generic filesystem declaration EDSL (used by both servers)
lib9p/ 9P protocol library + native client
env/ Session environment helpers
log/ Structured logging (olliesrv)
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)

Parallel Tool Execution

Tool calls within a single turn are dispatched in parallel using resource-based conflict scheduling. Each tool declares a scope in its .meta:

  • "read" — never conflicts (always parallel with everything)
  • "write" — conflicts only on the same file path (different files run in parallel)
  • "global" (or unset) — full serialization barrier (runs alone)

This means N file edits on different paths complete in one round-trip instead of N sequential calls. Shell is always a barrier (global scope) since its effects are opaque.

Background Processes

Any tool call can include "background": true to execute asynchronously. The result is an immediate process ID; the tool runs in toolsrv's proc/new.bg with no timeout. Output streams in real-time into proc/{id}/out and is automatically injected into the model's context as <system-proc-interrupt> blocks alongside subsequent tool results. The model can react to build failures, log events, etc. without polling.

Process lifecycle is connection-based: toolsrv owns all procs, and session death (toolsrv exit) kills everything via KillAll() + Pdeathsig. Exited procs remain in the tree for 10 minutes after last read, then are garbage collected. The model controls procs via proc/{id}/ctl (term, kill, dismiss).

Dispatch Flags

All tools automatically receive four optional parameters (injected into schemas at runtime):

  • bypass — run outside sandbox via bypass broker
  • timeout — execution timeout in seconds (0 = no timeout; background forces 0)
  • sandbox — sandbox profile name
  • background — run asynchronously, auto-inject output

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
├── bypass/
│   ├── policy             global bypass policy
│   └── pending/{id}       pending bypass 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 (rdwr: kill, save, invalidate, pause, resume)
    │   ├── id             immutable session UUID
    │   ├── name           mutable session name (write to rename)
    │   ├── bypass        per-session bypass policy
    │   └── agent/
    │       ├── new        write config to create agent (rdwr)
    │       └── {aname}/
    │           ├── prompt        submit a prompt
    │           ├── fifo          prompt queue (write=enqueue, read=dequeue)
    │           ├── feed          change-detecting input (write=store, read=block until changed)
    │           ├── chat          streaming filtered text (no markers/fences)
    │           ├── chat.raw      streaming full markup (block markers + fences)
    │           ├── log           last 64KB of chat (non-blocking)
    │           ├── statewait     blocking read until state changes
    │           ├── cfg           agent config (key=value)
    │           ├── ctl           agent control (rdwr): stop, compact, clear, inject,
    │           │                 agent, model, models, tools, tool_load, cwd, name,
    │           │                 backend, systemprompt
    │           ├── stats         usage=, cost=, ctxsz= (key=value lines)
    │           ├── plan          agent-scoped markdown checklist
    │           ├── id            immutable agent UUID
    │           ├── name          mutable agent name
    │           └── 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 Rdwr 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 pub/sub bus with hierarchical wildcard routing. The /eventwait file is a BlockOnce read that blocks until the next event arrives. Events are published with dotted topic paths (session.{sid}.agent.{aid}.state) and propagate to ancestor wildcards (session.*, *). Frontends block on /eventwait and receive events without polling.