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
shellandreasoning_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
FsNodeDecltree infs/spec.go, validated and built byBuildTree()infs/builder.go. Adding a file means adding aLeaf()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:
- Stream — call
backend.ChatStream()with messages + tool definitions - Dispatch — if the model returns tool calls, execute them via
toolsrv.Server - Update — append assistant message + tool results to session history
- 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 brokertimeout— execution timeout in secondssandbox— sandbox profile namebackground— 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) thensession/{name}/agent/new(full config) - Sessions and agents have immutable UUIDs (
idfile) and mutable human-readable names (namefile); writing tonamerenames the entry - Tool loading is a file write — writing a tool name to
agent/{id}/toolsloads it for the agent; reading it lists currently loaded tools - Global tool catalog at
/toolslists all discoverable tools on disk - Prompts, state, chat history, and config are readable/writable files
- Streaming via blocking reads:
chat,statewait,eventwaitblock 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— seedoc/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:
promptis mode0666— group+other can write, owner (the agent) cannotchatis mode0444— read-only for everyonectlis mode0666— anyone can send control commandsplanis mode0666— readable by peer agentseventwaitis0444— blocking read for global event streamsession/newis0666withRequesthandler — anyone can create sessions- Ownership inherits: empty
UID/GIDvalues 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 IDAll(tree)— list all sessionsCreateFromRoot(tree, args)— create a new session (two-step: session name, then agent creation)KillFromRoot(tree, id)— kill a sessionRenameFromRoot(tree, old, new)— rename a sessionShutdown(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.