big refactor: agent core, 9p sessions, webui, architecture docs
This commit is contained in:
parent
71e2dfc439
commit
d08497d287
2
9p
2
9p
|
|
@ -1 +1 @@
|
|||
Subproject commit 06a3e0f5031c99a85d3088b7393f7a700b0a259c
|
||||
Subproject commit e7dd3987efcafeca91740d8e5c664a83343257c9
|
||||
|
|
@ -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
2
core
|
|
@ -1 +1 @@
|
|||
Subproject commit 82ac183357358b7a00b408a940e57ec1218a0c31
|
||||
Subproject commit 9d4818af8da4ce238181aa83bd9ed0332260f109
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
2
webui
|
|
@ -1 +1 @@
|
|||
Subproject commit 0ca3d38ec8cc2bf91cf02432ea0aa3205262293a
|
||||
Subproject commit c61a674289a51c63fd6001880470ea96fafba2ef
|
||||
Loading…
Reference in New Issue