275 lines
16 KiB
Markdown
275 lines
16 KiB
Markdown
# 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 (`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
|
|
├── 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 (submodule) — standalone GUI, Kate plugin, KRunner
|
|
├── 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
|
|
```mermaid
|
|
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:
|
|
```go
|
|
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:
|
|
```go
|
|
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:
|
|
```go
|
|
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):
|
|
```go
|
|
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.
|