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
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. 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:
- 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 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 brokertimeout— execution timeout in seconds (0 = no timeout; background forces 0)sandbox— 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)
│ ├── 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) 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/newis0666withRdwrhandler — 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 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.