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 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 (ollie) with one Git submodule (kde/) for the KDE frontend:

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
│   └── ollie-remote/    Remote execution server
├── toolsrv/         Client library (9P client for toolsrv)
├── fsedsl/          Filesystem declaration EDSL (generic, reusable)
├── env/             Environment variable loading
├── log/             Structured logging
├── paths/           XDG path resolution
├── format/          Chat log formatting constants
├── 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, 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· 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
    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
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
fsedsl/ Generic filesystem declaration EDSL (used by both servers)
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 PID; the tool runs in toolsrv's proc/new.bg. Background process output is automatically injected into the model's context as <system-proc-interrupt> blocks at safe points (alongside subsequent tool results). The model can react to build failures, log events, etc. without polling.

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
  • 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)
    │           ├── 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 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.