ollie/doc/ARCHITECTURE.md

13 KiB

ollie Architecture

Philosophy

ollie is a small, extensible core library for building agentic systems. It has no binary of its own — frontends and consumers live in separate repos and import ollie as a dependency. The internal surface area is kept minimal; everything a consumer needs is exported under pkg/.

Overview

Package Layout

pkg/
  agent/         — Core interface, agent loop, session and context management
  backend/       — Backend interface + implementations
  config/        — Agent config struct and loader
  env/           — Environment variable loading (env file + shell)
  log/           — Structured logging
  paths/         — Config and data directory resolution
  skills/        — Skill file discovery and loading
  store/         — Store interfaces and implementations (FlatDir, Session, Batch, Skill)
  tools/         — Server and Dispatcher interfaces
  tools/execute/ — execute.Server: execute_code

internal/
  sandbox/       — landrun sandbox config and command wrapper

Why internal/sandbox?

The sandbox package is an implementation detail of pkg/tools/execute. It has no stable public API and no reason to be imported by consumers directly. Keeping it internal prevents accidental coupling.

Agent Configuration

Agent configs are JSON files in ~/.config/ollie/agents/. The key fields are:

prompt — an array of shell commands whose stdout is concatenated to form the system prompt. Each command runs in the session's working directory with access to $OLLIE (mount path) and $PWD. Commands that exit non-zero are skipped; exit code 2 blocks the turn entirely.

{
  "prompt": [
    "$OLLIE/x/prime SYSTEM_PROMPT",
    "$OLLIE/x/prime tools-file",
    "$OLLIE/x/prime tools-reasoning"
  ]
}

hooks — lifecycle callbacks executed at specific points:

Hook When Use case
agentSpawn Session creation Start elevation adapter, initialize state
preTurn Before each turn (deprecated; use prompt) Legacy prompt injection
postTurn After each turn Task list display, logging
preCompact Before context compaction Save state
postCompact After context compaction Notify user
turnError On turn error (rate limit, transient failure) Fallback handling, model switching

trustedTools — array of tool names allowed to run outside the sandbox.

mcpServers — MCP server configurations for external tool providers:

{
  "mcpServers": {
    "server-name": {
      "command": "server-binary",
      "args": [],
      "env": {"KEY": "value"}
    }
  }
}

Generation parameters — maxTokens, maxCompletionTokens, temperature, topP, topK, minP, topA, frequencyPenalty, presencePenalty, repetitionPenalty, reasoning, reasoningEffort, includeReasoning, responseFormat, stop, verbosity. Set in the agent JSON; overridable at runtime via s/{id}/cfg.

Extension Points

Consumers extend ollie by implementing or composing its interfaces:

  • backend.Backend — swap or add LLM backends
  • tools.Server — add a new tool server; all servers are equal
  • tools.Dispatcher — replace the tool router entirely (e.g. remote dispatcher, mock)
  • agent.Core — the agent's public API; frontends drive it without knowing internals. Includes Close() to release session resources (tmpdir under /tmp/ollie/).

All tool servers implement the same tools.Server interface. There is no special "builtin" concept — execute.Server is registered by name, and is torn down and recreated on /agent switches.

Each built-in server package exports a Decl function (func Decl(...) func() tools.Server) — a parameterized factory that produces a fresh server instance. tools.NewDispatcherFunc takes a map of name→Decl result and returns a func() tools.Dispatcher suitable for agent.AgentCoreConfig.NewDispatcher.

execute.Decl(cwd string) accepts a working directory used as cmd.Dir for sandboxed commands. Pass "" to fall back to os.Getwd().

Adding a new tool server:

  1. Implement tools.Server in a new package under pkg/tools/
  2. Export func Decl(...) func() tools.Server
  3. Register the Decl result by name in tools.NewDispatcherFunc — no frontend changes needed

Data Flow

Frontend
  └── agent.Core.Submit(prompt)
        └── loop.run()
              ├── backend.ChatStream()              — LLM call
              └── tools.Dispatcher.Dispatch(...)   — tool dispatch
                    ├── "execute"   → tools.Server
                    │       execute_code
                    └── "<servername>"  → tools.Server

All tool calls go through one tools.Dispatcher.Dispatch path. Every registered server is a tools.Server; the dispatcher routes by name and is agnostic to how any server is implemented.

execute_code

execute_code is the only built-in tool. It is a unified primitive that handles simple execution, sequential composition, and parallel fan-out through a single steps array of pipeline stages.

Each stage is one of:

  • {code, language} — inline code (default: bash)
  • {tool, args} — named script from OLLIE_TOOLS_PATH; language detected from shebang
  • {parallel: [...]} — concurrent fan-out; each element is a {code} or {tool} step; outputs concatenated in submission order

Stages run sequentially, each stage's stdout fed to the next stage's stdin. A single simple stage is the degenerate case and returns raw output directly.

steps=[{code: "ls"}]                          — simple execution
steps=[{code: "grep error log"}, {code:"wc"}] — sequential pipeline
steps=[{parallel:[{code:"wc a"},{code:"wc b"}]}] — parallel fan-out
steps=[{parallel:[...]}, {code:"sort"}]        — fan-out then transform

OLLIE_TOOLS_PATH defaults to ~/.config/ollie/tools. {tool} steps resolve scripts from this path.

Script Namespaces

User-facing scripts are organized by the 9P namespace they are hosted under and installed to ~/.config/ollie/scripts/{ns}/:

Namespace Purpose Scripts
s/ Session management and one-shot queries sh, ls, kill, cleanup, b, bfg, bbg
u/ Utility scripts; compositions of s/ primitives optimize
x/ System exec and plugins elevate, elevate-adapter-*, bd, prime, freeloader, task

s/b is the core one-shot runner. s/bfg and s/bbg are thin wrappers: s/bfg blocks until the agent finishes and writes the result to stdout; s/bbg returns immediately and prints the session path. u/ scripts have no special server backing — they are executable scripts that compose other namespaces via the filesystem.

Tool Scripts

All capabilities beyond execute_code itself are implemented as tool scripts in OLLIE_TOOLS_PATH, invoked via {tool} steps. The agent discovers available scripts via s/{sid}/t/idx.

Examples from the default install: file_read, file_write, file_edit, file_glob, file_grep, memory_remember, memory_recall, reasoning_think, task_add, task_check, task_clear, denote_view, browser_screencap, subagent_spawn.

Sub-agents

Sub-agents are implemented as a tool script (subagent_spawn) on top of the s/ namespace. The script writes a session spec to s/new, blocks on s/{id}/statewait per job in background subshells, joins on all, and concatenates results. The s/ namespace handles agent lifecycle, cancellation, and result storage.

For heterogeneous parallel delegation (N agents, N different prompts), a {parallel:[...]} stage composes with subagent_spawn — each parallel step is one shell invocation with a distinct prompt:

execute_code {parallel} stage
  ├── subagent_spawn "prompt A"  →  s/{id-0}/chat
  ├── subagent_spawn "prompt B"  →  s/{id-1}/chat
  └── subagent_spawn "prompt C"  →  s/{id-2}/chat
                                        ↓
                               concatenated in submission order

The architecture boundary is clean: execute_code is the only built-in tool in core; everything else is a tool script; s/ is a 9P namespace. No layer knows about the others.

Typical Consumer Setup

newDispatcher := tools.NewDispatcherFunc(map[string]func() tools.Server{
    "execute": execute.Decl(cwd),  // "" falls back to os.Getwd()
})

env := agent.BuildAgentEnv(cfg, newDispatcher(), cwd)

core := agent.NewAgentCore(agent.AgentCoreConfig{
    Backend:       be,
    CWD:           cwd,
    Env:           env,
    NewDispatcher: newDispatcher,
    // ...
})

BuildAgentEnv wires together the pre-registered servers with the config. On /agent switches, NewDispatcher is called to produce a fresh dispatcher — all servers are torn down and recreated for the new agent config. CWD is preserved across switches.

Session Lifecycle

NewAgentCore creates /tmp/ollie/{sessionID} when a session starts. Core.Close() removes it recursively. olliesrv calls Close() from killSession (individual session removal) and Shutdown (server stop — iterates all sessions). SetSessionID renames the tmpdir atomically alongside the persisted session files.

!<cmd> shell commands run in the session's current working directory (Core.CWD()), not the process working directory.

Session and Context

agent.Session owns the message history as a flat []backend.Message slice. There is no rolling window or automatic eviction — the history grows until compaction runs.

Compaction runs automatically before a turn when the estimated token count reaches 75% of the model's context length (defaulting to 128k if the backend doesn't report one). It can also be triggered manually via /compact (from s/sh or the ctl file). Either way it makes a single LLM call to summarize the conversation, then replaces the history with a compact representation:

  1. All system messages are preserved verbatim.
  2. Recent user messages are preserved up to a ~20 000-token budget (newest first, estimated at 4 chars/token).
  3. The LLM-generated summary is appended as a user message with a handoff prefix so the next turn can resume seamlessly.

Tool call/result pairs are flattened to plain text before the compaction request so no tool schemas are needed for the summarization call. Before compaction runs, a full snapshot of the pre-compaction history is written to {sessionsDir}/{sessionID}.compaction.jsonl for recovery.

/context and /usage commands report the current message breakdown and token usage without modifying history.

Sandboxing

Sandboxing is applied specifically to execute_code. Each code step is wrapped with landrun (Landlock LSM) via internal/sandbox. The sandbox config is loaded by name from ~/.config/ollie/sandbox/<name>.yaml; if no sandbox argument is passed in the execute_code call, the default config is used.

landrun is required. If landrun is not installed or not in PATH, execute_code will refuse to run and return a hard error. There is no unsandboxed fallback.

Steps marked elevated: true run outside the sandbox via the elevation backend (x/elevate) and are the only exception. All other steps are sandboxed unconditionally.

HTTP Gateway

ollie-httpgw is a thin translation layer between HTTP and 9P. It connects to olliesrv as a 9P client and exposes the same namespace over REST-style HTTP, so any HTTP client can interact with ollie without a 9P library.

Each HTTP method maps to a 9P primitive: GET → read/stat, POST → write, DELETE → remove, PATCH → wstat (rename). The gateway serializes file content to JSON where the format is well-defined (key-value files, tab-separated records, session index); raw text falls through as-is.

Sensitive internal paths (tr/, help, statewait, systemprompt, etc.) are blocked at the gateway layer and never exposed over HTTP.

HTTP client (browser, curl, script)
        │  HTTP/JSON  :8080
        ▼
  ollie-httpgw
        │  9P protocol
        ▼
  olliesrv  ($NAMESPACE/ollie  or  tcp:9564)

The gateway can connect to a local server via the Plan 9 namespace (default) or to a remote server via TCP (-net tcp -addr host:9564), making it equally useful for local setups and container/remote deployments.

Frontends

All frontends interact with the same 9P namespace, either directly or via httpgw:

Frontend Interface Talks to
s/sh terminal (readline) 9P filesystem directly
ellie Emacs buffer 9P filesystem directly
Web UI browser (Preact SPA) ollie-httpgw over HTTP

The web UI (webui/) is a Preact single-page application. It polls /s/{id}/chat every two seconds for new output and proxies all API paths to ollie-httpgw. It has no knowledge of 9P; the gateway is the only boundary.

Frontends that use the filesystem (s/sh, ellie, web UI) are decoupled from the implementation entirely — they work equally well against a local or remote olliesrv.