276 lines
12 KiB
Markdown
276 lines
12 KiB
Markdown
# 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
|
|
|
|
<img src="architecture.svg?v=6" width="60%">
|
|
|
|
## 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.
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"server-name": {
|
|
"command": "server-binary",
|
|
"args": [],
|
|
"env": {"KEY": "value"}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Generation parameters** — `maxTokens`, `temperature`, `frequencyPenalty`, `presencePenalty`.
|
|
|
|
## 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 `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
|
|
|
|
```go
|
|
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](https://github.com/landlock-lsm/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.
|
|
|
|
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`.
|