ollie/doc/ARCHITECTURE.md

175 lines
9.3 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/`.
## Package Layout
```
pkg/
agent/ — Core interface, agent loop, session and context management
backend/ — Backend interface + implementations
config/ — Agent config struct and loader
mcp/ — MCP client (concrete)
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.
### Why pkg/mcp is concrete (not interfaced)
`pkg/mcp` exposes a concrete `Client` rather than an interface. MCP is a well-defined protocol; the client is a thin transport layer and there is currently no need for consumers to swap implementations. This may be revisited.
## 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 (built-in or MCP-backed); 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 regardless of whether they are built-in or backed by MCP. There is no special "builtin" concept — `execute.Server` is registered by name the same way MCP servers are, and is torn down and recreated on `/agent` switches just like MCP connections.
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`. `tools.NewServer(client)` wraps an `mcp.Client` as a `tools.Server`.
`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 (MCP)
```
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 | `sh`, `ls`, `kill` |
| `b/` | Batch job submission and execution | `job`, `q`, `sched` |
| `u/` | Utility scripts; compositions of `b/` primitives | `optimize` |
`b/job` is the core batch runner. `b/q` and `b/sched` are thin wrappers that add foreground-wait and background-submit semantics respectively. `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_plan`, `task_complete`, `subagent_spawn`.
## Sub-agents
Sub-agents are implemented as a tool script (`subagent_spawn`) on top of the `b/` batch namespace. The script writes a job spec to `b/new`, polls `b/{id}/status` per job in background subshells, joins on all, and concatenates results. The `b/` namespace (backed by `BatchStore`) 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" → b/{id-0}/result
├── subagent_spawn "prompt B" → b/{id-1}/result
└── subagent_spawn "prompt C" → b/{id-2}/result
↓
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; `b/` is a 9P namespace. No layer knows about the others.
## Planning
`task_plan` saves a markdown checklist to the plan directory (`pl/` in ollie-9p) as a `__todo.md` file. The agent then:
1. Renames `__todo` → `__wip` when work begins
2. Calls `task_complete` after each step finishes — marks `[ ]` → `[x]`, returns remaining steps
3. Uses `file_*` scripts to add, remove, or revise steps as the plan evolves
4. Renames `__wip` → `__done` when the goal is realized
`task_complete` takes the plan filename and a step title (substring match, case-insensitive). It errors if no matching unchecked step is found, so silent skips are caught.
## 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) // also connects MCP servers from cfg
core := agent.NewAgentCore(agent.AgentCoreConfig{
Backend: be,
CWD: cwd,
Env: env,
NewDispatcher: newDispatcher,
// ...
})
```
`BuildAgentEnv` adds MCP servers from the config on top of the pre-registered servers. On `/agent` switches, `NewDispatcher` is called to produce a fresh dispatcher — all servers (built-in and MCP) are torn down and recreated for the new agent config. `CWD` is preserved across switches.
`Core.ListServers()` returns all registered tool servers and their tools, grouped by server name. Accessible via the `/mcp` command or `ollie/s/{sid}/mcp` in ollie-9p.
## 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. A `contextBuilder` enforces a rolling window to prevent unbounded prompt growth. When the window fills, older messages are either evicted (with a compaction notice injected) or proactively summarized via `/compact`.
## Sandboxing
`internal/sandbox` wraps commands with [landrun](https://github.com/landlock-lsm/landrun) (Landlock LSM). Configuration is layered:
1. Global defaults (`~/.config/ollie/sandbox.yaml`)
2. Named sandbox overlay (`~/.config/ollie/sandbox/<name>.yaml`)
If `superpowerd` is running, commands are additionally wrapped with `superpowers run-session` for privilege scoping (optional, detected at runtime).
## Frontends
The reference frontend is [ollie-tui](https://github.com/lneely/ollie-tui): a readline-based terminal UI that imports ollie as a library. It is the only consumer of `agent.Core` today, but the interface is intentionally frontend-agnostic.