big refactor: agent core, 9p sessions, webui, architecture docs

This commit is contained in:
Levi Neely 2026-05-21 14:42:55 +02:00
parent 71e2dfc439
commit d08497d287
6 changed files with 1061 additions and 256 deletions

2
9p

@ -1 +1 @@
Subproject commit 06a3e0f5031c99a85d3088b7393f7a700b0a259c
Subproject commit e7dd3987efcafeca91740d8e5c664a83343257c9

View File

@ -7,13 +7,15 @@
"maxSteps": 20,
"temperature": 0.3,
"maxTokens": 8192,
"allowExecutors": ["call_tool"],
"allowExecutors": ["call_tool", "execute_code"],
"allowTools": [
"file_read",
"file_glob",
"file_grep",
"file_write",
"file_edit"
"file_edit",
"subagent_generate",
"subagent_spawn"
],
"hooks": {
"turnError": [

2
core

@ -1 +1 @@
Subproject commit 82ac183357358b7a00b408a940e57ec1218a0c31
Subproject commit 9d4818af8da4ce238181aa83bd9ed0332260f109

View File

@ -2,40 +2,380 @@
## 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/`.
ollie asks: *What if an AI agent were a Plan 9 process?* The answer is a small Go runtime that defines what an agent is, then exposes its state and behaviors as files in a 9P namespace. Orchestration, scheduling, workflows, and UIs are pushed out to the surrounding environment — shell scripts, editors, web apps, cron, containers — rather than being built into the agent itself.
## Overview
Design principles:
- **Everything is a file.** Agent state, control, chat history, tools, memory — all accessed via filesystem reads and writes.
- **Subtract, don't add.** The core is minimal; capabilities come from composing external scripts.
- **One built-in tool.** `execute_code` is the only tool compiled into the core. Everything else is a script.
- **No framework lock-in.** Frontends are decoupled via the filesystem interface; any program that can read/write files can drive an agent.
<img src="architecture.svg?v=6" width="60%">
## Repository Structure
## Package Layout
The project is a monorepo with Git submodules:
```
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
ollie/
├── core/ → ollie-core Go library: agent loop, backends, tools, config
├── 9p/ → ollie-9p 9P filesystem server (olliesrv)
├── httpgw/ → ollie-httpgw HTTP-to-9P gateway
├── webui/ → ollie-webui Preact SPA (browser frontend)
├── acme/ → ollie-acme Plan 9 acme editor frontend
├── el/ → ollie-el Emacs frontend (ellie.el)
├── skills/ → agent-skills Domain knowledge files
├── agents/ Agent config JSONs (default, coding, orchestrator, worker, ...)
├── prompts/ Prompt templates (markdown)
├── tools/ Tool scripts (file_read, lsp_*, memory_*, subagent_*, ...)
├── scripts/ User-facing scripts organized by namespace (s/, u/, x/)
├── sandbox/ Sandbox config YAML
└── doc/ Documentation
```
### Why internal/sandbox?
## System Overview
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.
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontends │
│ s/sh (terminal) │ acme │ ellie (Emacs) │ Web UI (Preact)│
└────────┬──────────┴────┬───┴────────┬─────────┴───────┬─────────┘
│ 9P direct │ 9P direct │ 9P direct │ HTTP
│ │ │ ▼
│ │ │ ┌─────────────┐
│ │ │ │ ollie-httpgw│
│ │ │ └──────┬──────┘
▼ ▼ ▼ │ 9P
┌────────────────────────────────────────────────────────▼────────┐
│ olliesrv (9P server) │
│ ┌──────────┐ ┌──────────┐ ┌──────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ Sessions │ │ Memory │ │Skills│ │Tmp │ │ Tr │ │ ...│ │
│ │ s/ │ │ m/ │ │ sk/ │ │tmp/│ │tr/ │ │ │ │
│ └────┬─────┘ └──────────┘ └──────┘ └────┘ └────┘ └────┘ │
└───────┼─────────────────────────────────────────────────────────┘
│ per-session
▼
┌────────────────────────────────────────────────────────────────┐
│ agent.Core (per session) │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Agent Loop │ │ Session │ │ tools.Dispatcher │ │
│ │ (loop.go) │ │ (messages, │ │ ┌──────────────────┐ │ │
│ │ │ │ task state, │ │ │ execute.Server │ │ │
│ │ stream ←────┼──┤ compaction) │ │ │ (execute_code, │ │ │
│ │ dispatch ───┼──┼───────────────┼──┤ │ call_tool, pipe)│ │ │
│ └─────────────┘ └──────────────┘ │ └────────┬─────────┘ │ │
│ └───────────┼────────────┘ │
└──────────────────────────────────────────────────┼──────────────┘
│
┌──────────────────────────────────────────┼──────┐
│ Tool Execution │ │
│ ┌─────────────┐ ┌───────────────────┐ │ │
│ │ landrun │ │ Tool Scripts │ │ │
│ │ (sandbox) │ │ (OLLIE_TOOLS_PATH)│ │ │
│ └─────────────┘ └───────────────────┘ │ │
└──────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ LLM Backends │
│ Ollama│OpenAI│ │
│ Anthropic│... │
└─────────────────┘
```
## Core Library (`core/`)
The core is a Go library (`ollie/pkg/`) with no binary. Frontends import it as a dependency.
### Package Layout
| Package | Purpose |
|---|---|
| `pkg/agent/` | Agent loop, Core interface, Session, TaskState, hooks, prompt resolution |
| `pkg/backend/` | Backend interface + implementations (Ollama, OpenAI, Anthropic) |
| `pkg/config/` | Agent config struct and JSON loader |
| `pkg/env/` | Environment variable loading (env file + shell) |
| `pkg/log/` | Structured logging |
| `pkg/paths/` | Config and data directory resolution |
| `pkg/tools/` | Server and Dispatcher interfaces |
| `pkg/tools/execute/` | The execute.Server (execute_code, call_tool, pipe) |
| `internal/sandbox/` | Landlock sandbox config and command wrapper |
### Key Interfaces
**`agent.Core`** — the public API frontends drive:
```go
type Core interface {
Submit(ctx context.Context, input string)
Interrupt(cause error) bool
Queue(prompt string)
State() string // "idle" | "thinking" | "calling: <tool>"
WaitChange(ctx context.Context, field, current string) (string, bool)
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
}
```
**`tools.Server`** — any tool provider:
```go
type Server interface {
ListTools() ([]ToolInfo, error)
CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error)
}
```
**`tools.Dispatcher`** — routes tool calls to servers:
```go
type Dispatcher interface {
AddServer(name string, s Server)
ListTools() ([]ToolInfo, error)
Dispatch(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error)
}
```
### Agent Loop (`pkg/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 `tools.Dispatcher`
3. **Update** — append assistant message + tool results to session history
4. **Repeat** — loop until the model produces a final text response (no tool calls)
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 (`pkg/agent/session.go`)
`Session` 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 75% 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 (`pkg/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 (`pkg/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 (`9p/`)
`olliesrv` is the central daemon. It implements a 9P2000 file server that exposes the entire agent namespace:
### Namespace Layout
| Path | Contents |
|---|---|
| `s/` | Sessions — each session is a directory |
| `s/{id}/chat` | Full chat history (streamable) |
| `s/{id}/prompt` | Write to submit a prompt |
| `s/{id}/ctl` | Control commands (stop, kill, compact, model, ...) |
| `s/{id}/cfg` | Session config (key=value) |
| `s/{id}/state` | Current state (idle/thinking/calling) |
| `s/{id}/statewait` | Blocking read until state changes |
| `s/{id}/plan` | Session-scoped markdown checklist |
| `s/{id}/t/` | Tool scripts visible to this session |
| `m/` | Persistent memory files |
| `sk/` | Domain skill files |
| `a/` | Agent definitions (JSON) |
| `p/` | Prompt templates |
| `u/` | Utility scripts |
| `x/` | System plugins and exec helpers |
| `tr/` | Saved transcripts |
| `tmp/` | Temporary storage |
### Per-Agent 9P Principals
Each session gets its own FUSE mount at `/tmp/ollie-mount-{uid}` via `ollie-9p-mount -u {uid}`. The session's numeric UID is used as the 9P user principal, enabling permission enforcement:
- An agent cannot write to its own `prompt` file (mode `0466` — owner has no write)
- Cross-session access is controlled by standard Unix permission semantics
- The `$OLLIE` env var points to the session's private mount
### Permission Model (`9p/fs/perm.go`)
All permissions are declared in a single registry (`fs.Perms`). Key design:
- `prompt` is mode `0466` — 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
### Session Manager (`9p/session/`)
The `Manager` handles session lifecycle:
- Creating sessions (write to `s/new`)
- Killing sessions (delete `s/{id}` or write "kill" to `s/{id}/ctl`)
- Renaming sessions
- Assigning unique numeric UIDs for 9P principals
- Wiring up `agent.Core` with backends and tool servers
## Tool System
### Architecture
The tool system has exactly one built-in server (`execute.Server`) that exposes three executors:
| Executor | Purpose |
|---|---|
| `execute_code` | Run inline code steps (bash, python3, perl, lua, ...) |
| `call_tool` | Run named scripts from `OLLIE_TOOLS_PATH` |
| `pipe` | Sequential pipeline chaining stdout→stdin across steps |
All other capabilities are **tool scripts** — executable files discovered from `OLLIE_TOOLS_PATH` (default: `~/.config/ollie/tools`).
### Tool Scripts
Tool scripts are plain executables with a structured header comment declaring their name, description, parameters, and parallelism annotation:
- `file_read`, `file_write`, `file_edit`, `file_glob`, `file_grep` — filesystem I/O
- `lsp_definition`, `lsp_references`, `lsp_rename`, `lsp_symbols`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion` — LSP bridge (Python)
- `memory_remember`, `memory_recall` — persistent memory
- `reasoning_think` — scratchpad for externalized reasoning
- `subagent_spawn`, `subagent_generate` — sub-agent lifecycle
- `route`, `cascade`, `escalate` — tiered delegation
Scripts annotated `ollie:parallel read` can be fanned out concurrently by the agent loop.
### Sandboxing (`internal/sandbox/`)
Every code execution step is wrapped with [landrun](https://github.com/landlock-lsm/landrun) (Landlock LSM). Configuration is YAML-based:
```yaml
general:
best_effort: true
filesystem:
ro: ["/usr", "/lib", "/etc", "{HOME}/.config/ollie"]
rox: ["/usr/bin", "/usr/local/bin"]
rw: ["{CWD}", "{TMPDIR}"]
network:
enabled: true
unrestricted: true
```
Template variables (`{CWD}`, `{HOME}`, `{XDG_*}`, `{OLLIE_*}`) are expanded at runtime. **landrun is mandatory** — there is no unsandboxed fallback. Steps marked `elevated: true` bypass the sandbox via the elevation backend (`x/elevate`).
## LLM Backends (`pkg/backend/`)
All backends implement the streaming-only `Backend` interface:
| Backend | Wire Format | Notes |
|---|---|---|
| `OllamaBackend` | `/api/chat` | Local models, context length from `/api/show` |
| `OpenAIBackend` | `/v1/chat/completions` | OpenAI, OpenRouter, any compatible API |
| `AnthropicBackend` | `/v1/messages` | Extended thinking, prompt caching |
Shared infrastructure:
- `streamRequest()` — common HTTP streaming with error classification
- `RateLimitError`, `TransientError`, `ContextOverflowError`, `ToolUnsupportedError` — typed errors for loop control
- `GenerationParams` — unified sampling parameters across all backends
## Multi-Agent System
Multi-agent coordination is built entirely on the filesystem primitive:
### Mechanism
1. **Spawn**: write session spec to `s/new` → new session directory appears
2. **Prompt**: write task to `s/{id}/prompt`
3. **Observe**: read `s/{id}/state` or block on `s/{id}/statewait`
4. **Result**: child writes to `s/{parent_id}/prompt` when done
### Delegation Tools
| Tool | Semantics |
|---|---|
| `route` | Orchestrator dispatch; accepts `--tier=worker\|expert` and `--backend=<name>` |
| `cascade` | Cheap worker delegation (fixed tier) |
| `escalate` | Strong expert delegation (fixed tier) |
| `subagent_spawn` | Raw session creation with full control |
| `subagent_generate` | JIT agent identity generation |
Tier resolution uses `OLLIE_TIER_{WORKER|EXPERT}_{backend}` env vars to map tiers to specific models per backend.
### Callback Protocol
```
[from={session_id}]
STATUS: done|error
SUMMARY: <result>
ARTIFACTS: <paths>
```
The parent never actively waits — results arrive as queued prompts.
## HTTP Gateway (`httpgw/`)
`ollie-httpgw` is a stateless translation layer:
| HTTP Method | 9P Operation |
|---|---|
| GET | read / stat / readdir |
| PUT | write (truncate) |
| POST | create |
| DELETE | remove |
| HEAD | stat (metadata in headers) |
| PATCH | wstat (rename) |
Connects to `olliesrv` via Plan 9 namespace (local) or TCP (remote). Sensitive paths (`tr/`, `statewait`, `systemprompt`) are blocked at the gateway layer.
## Frontends
All frontends interact with the same 9P namespace:
| Frontend | Technology | Connection |
|---|---|---|
| `s/sh` | Terminal (readline) | 9P filesystem directly |
| `ollie-acme` | Plan 9 acme editor | 9P filesystem directly |
| `ellie` | Emacs Lisp | 9P filesystem directly |
| Web UI | Preact SPA | HTTP via `ollie-httpgw` |
Frontends are fully decoupled — they work identically against local or remote `olliesrv` instances.
## Script Namespaces
User-facing scripts installed to `~/.config/ollie/scripts/{ns}/`:
| Namespace | Purpose | Key Scripts |
|---|---|---|
| `s/` | Session management | `sh` (interactive), `b` (one-shot), `bfg` (blocking), `bbg` (background) |
| `u/` | Utility compositions | `complete` (code completion), `optimize` (prompt optimization), `cascade`, `escalate` |
| `x/` | System plugins | `elevate` (privilege escalation), `prime` (prompt template renderer), `freeloader` |
## 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.
Agent configs are JSON files in `~/.config/ollie/agents/`:
```json
{
@ -43,243 +383,64 @@ Agent configs are JSON files in `~/.config/ollie/agents/`. The key fields are:
"$OLLIE/x/prime SYSTEM_PROMPT",
"$OLLIE/x/prime tools-file",
"$OLLIE/x/prime tools-reasoning"
]
],
"hooks": {
"agentSpawn": ["$OLLIE/x/elevate start"],
"turnError": ["$OLLIE/x/freeloader"]
},
"trustedTools": ["escalate"],
"maxTokens": 8192,
"temperature": 0.7,
"maxSteps": 50
}
```
**`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.
**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
The `prompt` array is the key innovation: each element is a shell command whose stdout is concatenated to form the system prompt. This makes prompts composable, dynamic, and environment-aware.
## 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
User input (via any frontend)
│
▼ write to s/{id}/prompt
olliesrv
│
▼ agent.Core.Submit()
agent loop (loop.go)
│
├── backend.ChatStream() → LLM API call (streaming)
│
├── tools.Dispatcher.Dispatch() → execute.Server
│ │
│ ├── {code} steps → landrun sandbox → subprocess
│ └── {tool} steps → landrun sandbox → tool script
│
├── session.update() → append to message history
│
└── loop (until no tool calls)
│
▼ event bus → session log
Frontend reads s/{id}/chat (streaming)
```
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.
## Extension Points
## execute_code
| What | How |
|---|---|
| Add an LLM backend | Implement `backend.Backend` |
| Add a tool | Drop an executable into `OLLIE_TOOLS_PATH` |
| Add a tool server | Implement `tools.Server`, register in `NewDispatcherFunc` |
| Add a frontend | Read/write the 9P namespace (directly or via httpgw) |
| Add a skill | Drop a markdown file into `skills/` |
| Add an agent persona | Create a JSON config in `agents/` |
| Customize sandbox | Create a YAML in `~/.config/ollie/sandbox/` |
`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.
## Key Design Decisions
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 | `complete`, `optimize`, `cascade`, `escalate` |
| `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`, `lsp_definition`, `lsp_references`, `lsp_rename`, `lsp_symbols`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `route`, `cascade`, `escalate`, `subagent_generate`, `subagent_spawn`, `denote_view`, `browser_screencap`.
## Sub-agents
Sub-agents are implemented as tool scripts (`subagent_spawn`, `cascade`, `route`, `escalate`) on top of the `s/` namespace. Each script writes a session spec to `s/new`, waits for the session directory to appear, then submits a prompt. All are fire-and-forget: the parent continues immediately and receives results asynchronously when the child writes to `s/{parent_session_id}/prompt`.
**Routing and tiers**: `route` is the orchestrator's primary dispatch tool. It accepts `--tier=worker|expert` and `--backend=<name>`, resolving the model from `OLLIE_TIER_{WORKER|EXPERT}_{backend}` env vars. `cascade` and `escalate` are simpler variants with fixed tier semantics (cheap worker and strong expert respectively).
**Callback protocol**: when a sub-agent finishes, it writes a structured result to the parent's prompt file:
```
[from={session_id}]
STATUS: done|error
SUMMARY: <result>
```
The parent receives this as its next queued turn — no polling, no blocking, no active waiting.
For heterogeneous parallel delegation (N agents, N different prompts), multiple `route`/`cascade` calls fan out concurrently (tool scripts are annotated `ollie:parallel read`):
```
call_tool: calls=[
{tool: "route", args: ["implement X in /src/a.go"]},
{tool: "route", args: ["implement Y in /src/b.go"]},
{tool: "route", args: ["redesign Z", "--tier=expert"]}
]
```
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
### Per-Agent 9P Principals
Each agent session gets its own FUSE mount at `/tmp/ollie-mount-{sessionID}`, created by `ollie-9p-mount -u {sessionID}`. The session ID is used as the 9P user principal, so the server can enforce per-agent file permissions. The agent's `$OLLIE` env var points to this private mount — not the user's global mount.
This enables 9P permission enforcement: an agent cannot write to its own `prompt` file (preventing self-prompting loops), cannot read other agents' private state unless explicitly permitted, and cannot escalate its own privileges.
### Tmpdir and Cleanup
`NewAgentCore` creates `/tmp/ollie/{sessionID}` when a session starts. `Core.Close()` removes it recursively and tears down the FUSE mount. 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.
**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`.
1. **9P as the integration layer** — not REST, not gRPC, not a library API. Files are the universal interface.
2. **Single built-in tool** — `execute_code` is the only compiled-in capability. This keeps the core small and makes all other tools equal citizens.
3. **Mandatory sandboxing** — no unsandboxed fallback. Security is not optional.
4. **Streaming-only backends** — simplifies the interface; blocking APIs wrap as single-event streams.
5. **Fire-and-forget sub-agents** — no active waiting, no polling. Results arrive as filesystem writes.
6. **Prompt-as-code** — system prompts are assembled by running shell commands, not by concatenating static strings.
7. **Per-agent principals** — 9P permission enforcement prevents self-prompting loops and enforces access boundaries without application-level checks.

642
doc/CORE.md Normal file
View File

@ -0,0 +1,642 @@
# ollie-core: Detailed Architecture
This document covers the internal design of the `core/` library — the Go package that implements the agent engine. It is the dependency imported by all consumers (olliesrv, frontends, tests).
## Package Map
```
core/
├── pkg/
│ ├── agent/ The agent engine
│ │ ├── types.go Core interface, Event, PromptFIFO
│ │ ├── core.go AgentCoreConfig, agent struct, NewAgentCore, BuildAgentEnv
│ │ ├── loop.go The agent loop (run function)
│ │ ├── session.go Session, TaskState, compaction, persistence
│ │ ├── state.go state interface, ResultTier, toolResult
│ │ ├── hooks.go Hooks map, HookResult, hook execution
│ │ ├── commands.go Slash-command dispatch (/model, /agent, /compact, ...)
│ │ ├── cost.go Model pricing table, cost computation
│ │ └── prompt_resolver.go System prompt assembly from shell commands
│ ├── backend/ LLM provider implementations
│ │ ├── backend.go Backend interface, shared types, streamRequest
│ │ ├── openai.go OpenAI/OpenRouter/compatible APIs
│ │ ├── anthropic.go Anthropic Messages API
│ │ └── ollama.go Ollama local inference
│ ├── config/ Agent config JSON schema and loader
│ ├── env/ Environment variable loading
│ ├── log/ Structured logger
│ ├── paths/ XDG directory resolution
│ └── tools/ Tool system
│ ├── tools.go Server, Dispatcher, classifier interfaces
│ └── execute/ The execute.Server
│ ├── server.go Server struct, Decl factory, ListTools, options
│ ├── code.go Step resolution, validation, dispatch routing
│ ├── pipe.go Pipeline stage execution, parallel fan-out
│ ├── flock.go Advisory locking, parallelism classification
│ └── tier.go Result tier classification (hot/warm/cold)
└── internal/
└── sandbox/ Landlock sandbox wrapper
├── config.go YAML config, path expansion, access checking
└── wrapper.go landrun command construction
```
## The Core Interface (`pkg/agent/types.go`)
`Core` is the boundary between the engine and any consumer. It is deliberately narrow:
```go
type Core interface {
// Turn lifecycle
Submit(ctx context.Context, input string) // process user input (commands or prompts)
Interrupt(cause error) bool // cancel current turn
Inject(prompt string) // append to next tool result
Queue(prompt string) // FIFO for deferred prompts
PopQueue() (string, bool) // drain one queued prompt
// Observation
IsRunning() bool
State() string // "idle" | "thinking" | "calling: <tool>"
Reply() string // last assistant text
Bus() *pubsub.Bus // event stream
WaitChange(ctx context.Context, field, current string) (string, bool)
// Configuration
AgentName() string
BackendName() string
ModelName() string
CWD() string
SetCWD(dir string) error
SetSessionID(newID string) error
SetEnv(key, value string)
SetGenerationParams(params backend.GenerationParams) error
// Introspection
Context() []backend.Message
SystemPrompt() string
GenerationParams() backend.GenerationParams
CtxSz() string
Usage() string
Cost() string
ListModels() string
ToolCallCount() int64
// Cleanup
Close()
}
```
All output flows through the event bus as `Event{Role, Name, Content}` values. Roles include: `assistant`, `tool`, `call`, `info`, `exec`, `error`, `usage`, `reasoning`, `limitretry`.
## Agent Construction (`pkg/agent/core.go`)
### AgentCoreConfig
The full construction config:
| Field | Purpose |
|---|---|
| `Backend` | Active LLM backend instance |
| `ModelName` | Override backend's default model |
| `AgentName` | Name of the active agent config |
| `AgentsDir` | Path to agent JSON directory |
| `SessionsDir` | Path to persisted session files |
| `SessionID` | Unique session identifier |
| `Uname` | Immutable 9P user principal |
| `CWD` | Working directory for tools and prompt |
| `Session` | Pre-existing session to restore (nil = new) |
| `Env` | Pre-built AgentEnv (tools, hooks, prompt, params) |
| `NewDispatcher` | Factory for fresh Dispatcher on agent switch |
| `NewBackend` | Factory for backend by name |
| `Log` | Logger instance |
| `MaxSteps` | Override agent config's maxSteps |
### BuildAgentEnv
`BuildAgentEnv(cfg, dispatcher, cwd, env)` wires everything together:
1. Lists all tools from the dispatcher
2. Resolves the system prompt by executing the config's `prompt` commands
3. Extracts generation parameters from the config
4. Builds the tool executor closure (routes calls through the dispatcher)
5. Extracts parallelism and tier classifiers from the execute server
6. Returns an `AgentEnv` struct ready for `NewAgentCore`
### NewAgentCore
Creates the `agent` struct:
1. Sweeps stale tmpdirs from previous crashes
2. Creates `/tmp/ollie/{sessionID}` for this session
3. Sets up the plan file reader (reads `s/{id}/plan` for the next unchecked step)
4. Wires the `turnError` hook with a detached context
5. Pushes `OLLIE_SESSION_ID` and `OLLIE_UNAME` into the execute server env
6. Sets the flock directory for parallel coordination
## The Agent Loop (`pkg/agent/loop.go`)
The `run()` function is the core execution engine. It takes an `agentConfig` and a `state` interface, and loops until the model stops calling tools.
### Per-Iteration Flow
```
1. Context gate: strip cold material if over budget
2. Build message history:
- If TaskState exists: system prompt + task state + hot tail only
- Otherwise: system prompt + full history
3. Stream from backend (with retry logic)
4. If no tool calls: break (turn complete)
5. Execute tool calls (serial or parallel)
6. Update state (append messages + results)
7. Infer TaskState updates from what happened
8. Safety checks (error limits, stall, replan, max steps)
9. Auto-compact if needed
10. Loop
```
### Streaming & Retry
The streaming loop handles three failure modes:
| Failure | Detection | Recovery |
|---|---|---|
| Rate limit (429) | `*RateLimitError` | Exponential backoff (5×2^attempt seconds), up to 3 retries |
| Transient (5xx, network) | `*TransientError` | Same retry logic |
| Stream drop | `Done` never received, channel closes | Retry with 2×2^attempt second delay |
| Context overflow | `*ContextOverflowError` | Trigger compaction, retry |
| Tool unsupported | `*ToolUnsupportedError` | Propagate to turnError hook |
On the first error, the `turnError` hook fires. If it exits 0 (handled), the loop returns immediately — the hook is responsible for recovery (e.g., switching models via `freeloader`).
### Tool Execution
Tool calls are dispatched with parallelism awareness:
1. **Classify** each tool call via `ClassifyTool(name)` (reads `ollie:parallel` annotation)
2. **Batch** consecutive parallel-read-safe calls into a group
3. **Fan out** the batch concurrently with deduplication (identical calls share one execution)
4. **Serial** calls run one at a time
Per-call lifecycle:
```
preTool hook → execute → postTool hook → inject check → truncation → cache (if read-safe)
```
- `preTool` exit 2 blocks execution
- `postTool` exit 2 replaces the result
- Results exceeding `ToolResultMaxBytes` are hard-truncated
- Read-safe results are cached in a `sync.Map` for the duration of the turn
### Safety Mechanisms
| Mechanism | Threshold | Action |
|---|---|---|
| Consecutive errors (soft) | 5 rounds | Inject nudge: "try a different approach" |
| Consecutive errors (hard) | 10 rounds | Abort the turn |
| Replan gate | 8 rounds without PLAN: block | Inject replan nudge |
| Stall detection | 5 rounds with same LastAction | Inject stall nudge |
| Max steps | Configurable per-agent | Soft exit with nudge |
| Plan re-injection | Every 10 tool rounds | Re-inject task state |
### TaskState Inference
After each tool round, `inferTaskStateUpdate` updates the structured overlay:
- `LastAction` ← most recent tool call + truncated result
- `PlanStep` ← next unchecked item from the plan file (or heuristic from assistant text)
## Session & Context (`pkg/agent/session.go`)
### Message History
`Session` holds a flat `[]backend.Message` slice. No rolling window — grows until compaction.
Token tracking per session:
- `TotalInputTokens`, `TotalOutputTokens`, `TotalRequests`
- `TotalCachedInputTokens`, `TotalCacheCreationTokens`
- Per-turn accumulators reset at turn start
- `LastTurnCostUSD`, `SessionCostUSD`
### Compaction
Triggered when estimated tokens reach 75% of model context length.
**Three-zone strategy:**
| Zone | Content | Size |
|---|---|---|
| Cold | Structured TaskState JSON | 1 message |
| Warm | One-line decision index | ~10 messages |
| Hot | Verbatim recent messages | 8 messages |
Process:
1. Flatten tool call/result pairs to plain text
2. Send full history + compaction prompt to the LLM
3. Parse structured JSON response into TaskState
4. Rebuild history as cold + warm + hot zones
5. Write pre-compaction snapshot to `{id}.compaction.jsonl` for recovery
### Result Tiers
Tool results are classified at execution time:
| Tier | Behavior | Examples |
|---|---|---|
| Hot | Stays verbatim in messages | `file_write`, `file_edit`, `reasoning_think` |
| Warm | Summarized on next compaction | (custom tools) |
| Cold | Immediately collapsed to one-line summary | `file_read`, `memory_recall`, `lsp_*` |
Classification sources (in priority order):
1. Built-in table in `execute/tier.go` (`defaultColdTools`)
2. `ollie:tier` annotation in the tool script header
3. Default: hot
### Persistence
Sessions are saved as `PersistedSession` JSON:
```json
{
"id": "1716300000000-abc123",
"agent": "coding",
"messages": [...],
"taskState": {...}
}
```
Saved after every tool round (`SaveSession` callback) and on clean exit.
## Hooks (`pkg/agent/hooks.go`)
Hooks are shell commands executed with:
- **stdin**: JSON payload with context (session_id, cwd, model, error, etc.)
- **env**: `OLLIE_*` variables derived from the payload map
- **timeout**: 60 seconds (configurable in tests)
- **process group**: `Setpgid=true` for clean kill on timeout
### Exit Code Semantics
| Code | Meaning |
|---|---|
| 0 | Success. Stdout appended to context. |
| 2 | Block. Stops the action (e.g., blocks a tool call, prevents compaction). |
| Other | Non-blocking warning. Execution continues. |
### Hook Execution Order
Multiple commands per hook run sequentially. If any exits 2, execution stops immediately and returns `Blocked=true`.
## Commands (`pkg/agent/commands.go`)
`Submit()` first checks for commands before starting an agent turn:
| Prefix | Dispatch |
|---|---|
| `!<cmd>` | Shell command in session CWD |
| `/backend <name>` | Switch LLM backend |
| `/model <name>` | Switch model |
| `/agent <name>` | Switch agent config (rebuilds dispatcher) |
| `/compact` | Manual compaction |
| `/context` | Show message breakdown |
| `/usage` | Show token counts |
| `/cost` | Show cost |
| `/models` | List available models |
| `/agents` | List available agents |
| `/maxsteps [n]` | Get/set max steps |
| `/i <prompt>` | Inject into running turn |
| `/irw <prompt>` | Inject-rewrite |
### Agent Switching
When `/agent <name>` is issued:
1. Load the new agent config JSON
2. Create a fresh dispatcher via `newDispatcher()`
3. Call `BuildAgentEnv` with the new config
4. If the config specifies a backend/model, switch those too
5. Replace dispatcher, hooks, prompt, tools, params on the agent struct
6. Clear the session (fresh context for the new persona)
## Tool System (`pkg/tools/`)
### Dispatcher
The `Dispatcher` is a simple name→Server router:
```go
type Dispatcher interface {
AddServer(name string, s Server)
GetServer(name string) (Server, bool)
ListTools() ([]ToolInfo, error)
Dispatch(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error)
}
```
`NewDispatcherFunc` returns a factory that creates a fresh dispatcher with all servers instantiated. Called on agent switches to get clean server state.
### Optional Server Interfaces
Servers may implement additional interfaces for runtime configuration:
| Interface | Purpose |
|---|---|
| `CWDSetter` | Update working directory |
| `EnvSetter` | Inject session env vars |
| `LockDirSetter` | Set flock directory for parallel coordination |
| `ToolRestrictionSetter` | Restrict available executors/tools |
| `ParallelClassifier` | Report if a tool is read-safe |
| `TierClassifier` | Report result retention tier |
### execute.Server (`pkg/tools/execute/`)
The single built-in server exposes three executors:
#### execute_code
Runs inline code steps sequentially (or parallel via `{parallel: [...]}`).
**Input schema:**
```json
{
"steps": [{"code": "...", "language": "bash"}],
"timeout": 30,
"sandbox": "default"
}
```
**Supported languages:** bash, python3, perl, lua, awk, sed, jq, ed, expect, bc.
#### call_tool
Runs named tool scripts from `OLLIE_TOOLS_PATH`.
**Input schema:**
```json
{
"calls": [{"tool": "file_read", "args": ["/path"]}],
"timeout": 30,
"sandbox": "default"
}
```
Parallel fan-out is automatic for consecutive calls to `ollie:parallel read` tools.
#### pipe
Sequential pipeline — each stage's stdout feeds the next stage's stdin.
**Input schema:**
```json
{
"stages": [
{"tool": "file_grep", "args": ["TODO", "--path=src/"]},
{"code": "wc -l"}
],
"timeout": 30
}
```
### Code Validation
Before execution, inline code is checked against dangerous patterns:
- **Universal**: `mkfs`, `dd if=/dev/`, `sudo/su`, `/etc/shadow`
- **Bash**: recursive force-delete of system paths, fork bombs, `/dev/sd` writes, eval injection
- **Python**: `shutil.rmtree("/")`, subprocess rm -rf, os.remove of system paths
- **Perl**: system/exec rm -rf, backtick rm, unlink system paths
- **Lua**: os.execute rm -rf, os.remove system paths
Validation failures are rate-limited: 5 failures within 1 minute triggers a 5-minute block.
### Parallelism & Locking (`execute/flock.go`)
Tool scripts declare their concurrency class via header annotations:
| Annotation | Lock Class | Behavior |
|---|---|---|
| `ollie:parallel read` | Read | Shared lock (LOCK_SH); concurrent with other reads |
| `ollie:parallel write` | Write | Exclusive lock (LOCK_EX); serialized |
| (none) | Global | Serialized with everything |
Lock files live in `/tmp/ollie/{sessionID}/` and are acquired via `flock(2)`.
### Result Tier Classification (`execute/tier.go`)
Built-in cold tools (results consumed immediately, don't need verbatim retention):
- `file_read`, `memory_recall`, `web_fetch`, `web_search`
- `lsp_definition`, `lsp_references`, `lsp_hover`
Custom tools declare their tier via `ollie:tier cold|warm|hot` in the script header.
### Elevation
Steps marked `elevated: true` bypass the sandbox entirely. They are dispatched to the elevation backend (`x/elevate`) which runs outside landrun. Only bash is supported for elevated steps.
### FUSE Mount
When `WithMount()` is set, the execute server spawns `ollie-9p-mount -u {uname}` to create a per-session FUSE mount. This gives the agent its own 9P identity for permission enforcement. The mount path is exposed as `$OLLIE` in subprocess environments.
## Backend System (`pkg/backend/`)
### Shared Types
| Type | Purpose |
|---|---|
| `Message` | Conversation turn (role, content, tool_calls, tool_call_id) |
| `Tool` | Function declaration (name, description, JSON Schema params) |
| `ToolCall` | Model's request to invoke a function |
| `StreamEvent` | Incremental streaming delta (content, reasoning, tool_calls, done, usage) |
| `Usage` | Token counts + optional cost |
| `GenerationParams` | Unified sampling parameters |
### Error Types
| Error | HTTP Code | Loop Behavior |
|---|---|---|
| `RateLimitError` | 429 | Retry with backoff |
| `TransientError` | 5xx, network | Retry with backoff |
| `ContextOverflowError` | 400 (specific) | Compact and retry |
| `ToolUnsupportedError` | 400/422 (specific) | Propagate to turnError hook |
### streamRequest
Shared HTTP streaming infrastructure used by all backends:
1. Execute the HTTP request
2. Classify response status into typed errors
3. Spawn a goroutine that parses SSE/NDJSON into `StreamEvent` channel
4. Return the channel for the loop to consume
### Backend Implementations
**OllamaBackend** (`/api/chat`):
- Local inference, no API key
- Context length from `/api/show` (cached)
- Model list from `/api/tags`
- NDJSON streaming
**OpenAIBackend** (`/v1/chat/completions`):
- Works with OpenAI, OpenRouter, any compatible API
- Configurable base URL, name, extra headers
- Context length from model list endpoint (cached)
- SSE streaming (`data: {...}` lines)
- Handles both `tool_calls` and legacy `function_call`
**AnthropicBackend** (`/v1/messages`):
- Extended thinking (reasoning budget via `ThinkingBudget`)
- Prompt caching (cache_control blocks)
- 200k context window (hardcoded)
- SSE streaming with content_block_delta events
## Config System (`pkg/config/`)
### Agent Config Schema
```go
type Config struct {
Hooks map[string]HookCmds // lifecycle callbacks
Prompt Prompt // system prompt (string or command array)
Backend string // backend override
Model string // model override
Tools *bool // enable/disable tools (default: true)
AllowExecutors []string // restrict executors
AllowTools []string // restrict tool scripts
MaxTokens int // generation limit
MaxCompletionTokens int // OpenAI o-series
MaxSteps int // tool rounds per turn
Temperature *float64
TopP, TopK, MinP, TopA *float64/*int
FrequencyPenalty *float64
PresencePenalty *float64
RepetitionPenalty *float64
Reasoning int // Anthropic thinking budget
ReasoningEffort string // OpenAI: low/medium/high
IncludeReasoning *bool // OpenRouter
ResponseFormat string // json_object, text
Stop []string // stop sequences
Verbosity string // output detail level
}
```
### Prompt Type
Dual-mode unmarshaling:
- **JSON string**: treated as literal text (with `!command`, file path, or env expansion)
- **JSON array**: each element is a shell command; stdout concatenated
The array form is preferred — it enables composable prompt assembly from template fragments.
### HookCmds Type
Dual-mode: a single string `"cmd"` or an array `["cmd1", "cmd2"]`. Multiple commands run sequentially.
## Prompt Resolution (`pkg/agent/prompt_resolver.go`)
Two paths based on `Prompt.IsExec`:
**Exec mode** (array):
- Each command runs via `sh -c` with 30s timeout
- CWD set to session working directory
- Extra env vars injected (OLLIE_SESSION_ID, OLLIE mount path)
- Non-zero exit skips that command (no error propagation)
- All stdout concatenated with newlines
**String mode** (legacy):
- Environment variables expanded
- If contains newline: literal text
- If starts with `!`: execute as shell command
- If names an existing file: read file contents
- Otherwise: use as-is
## Cost Tracking (`pkg/agent/cost.go`)
When the backend doesn't report cost directly (only OpenRouter does), cost is computed from a built-in pricing table:
- Matches model name prefix (case-insensitive)
- Covers Anthropic Claude, OpenAI GPT/o-series, Google Gemini
- Cached input tokens charged at discount (10% Anthropic, 50% others)
- Cache creation tokens charged at 125% of input rate
- Unknown/local models return zero cost
## Sandbox (`internal/sandbox/`)
### Config Format
```yaml
general:
best_effort: true # don't fail if kernel doesn't support all features
log_level: "" # landrun log level
filesystem:
ro: ["/usr", "/lib", "/etc"] # read-only
rox: ["/usr/bin", "/usr/local/bin"] # read + execute
rw: ["{CWD}", "{TMPDIR}"] # read + write
rwx: [] # read + write + execute
network:
enabled: true
unrestricted: true # all TCP allowed
bind_tcp: [] # specific bind ports
connect_tcp: [] # specific connect targets
env: [] # extra environment variables
advanced:
ldd: false # resolve shared library paths
add_exec: false # add execute to all read paths
```
### Template Variables
| Variable | Expansion |
|---|---|
| `{CWD}` | Session working directory |
| `{HOME}` | User home directory |
| `{TMPDIR}` | Temp directory (default: /tmp) |
| `{XDG_CONFIG_HOME}` | XDG config (default: ~/.config) |
| `{XDG_DATA_HOME}` | XDG data (default: ~/.local/share) |
| `{XDG_CACHE_HOME}` | XDG cache (default: ~/.cache) |
| `{XDG_STATE_HOME}` | XDG state (default: ~/.local/state) |
| `{XDG_RUNTIME_DIR}` | XDG runtime (default: /run/user/UID) |
| `{OLLIE_CFG_PATH}` | ollie config directory |
| `{OLLIE_DATA_PATH}` | ollie data directory |
### Path Validation
`checkPath(cfg, path, write, cwd, getenv)` validates that a given absolute path falls within the sandbox's allowed paths. Symlinks are resolved before checking. Used by file tools to pre-validate paths before execution.
## Event System
The agent communicates exclusively through events published on a `pubsub.Bus`:
```go
type Event struct {
Role string // event type
Name string // tool name (for call/tool events)
Content string // payload
}
```
| Role | When |
|---|---|
| `assistant` | Streaming text delta from the model |
| `reasoning` | Thinking/reasoning delta (wrapped in `<think>` tags) |
| `call` | Tool call initiated (Name=tool, Content=args JSON) |
| `tool` | Tool result returned (Name=tool, Content=result) |
| `info` | Informational message (command output, status) |
| `exec` | Shell command output (`!cmd`) |
| `error` | Error message |
| `usage` | Token usage report (space-separated numbers) |
| `limitretry` | Rate limit retry initiated |
Consumers subscribe to the `"event"` topic on the bus. The 9P server's session manager subscribes to build the chat log; frontends subscribe to render output.
## Concurrency Model
- **One turn at a time**: `submitMu` serializes `Submit()` calls
- **Interrupt**: `currentAction` holds a `CancelCauseFunc`; `Interrupt()` calls it
- **Queue drain**: after each turn completes, queued prompts are drained sequentially
- **State observation**: `WaitChange()` uses a condition variable (`changeCond`) that broadcasts on any state/usage/cwd change
- **Tool parallelism**: within a turn, read-safe tools fan out via goroutines with `sync.Map` caching and `flock` coordination
## Session ID Format
```
<unix-nanoseconds>-<6-hex-chars>
```
Lexicographically sortable by creation time. Unique even within the same nanosecond due to the random suffix.

2
webui

@ -1 +1 @@
Subproject commit 0ca3d38ec8cc2bf91cf02432ea0aa3205262293a
Subproject commit c61a674289a51c63fd6001880470ea96fafba2ef