ollie/doc/architecture-core.md

427 lines
17 KiB
Markdown

# Core Architecture
This document describes the agent-side core. For the tool-execution service, its 9P namespace, registry, metadata, sandbox, process lifecycle, and cancellation path, see [`architecture-toolsrv.md`](architecture-toolsrv.md).
This document covers the internal Go packages that implement Ollie's agent runtime. The code lives under `cmd/olliesrv/internal/`; it is not a separately imported public library. `olliesrv` composes the agent, backend, session, filesystem, and tool-client packages.
## Package Map
```mermaid
flowchart TB
subgraph Agent["cmd/olliesrv/internal/agent/"]
AGENT["agent.go — Agent, events, interrupt"]
LOOP["loop.go — agent loop"]
HISTORY["history.go — history, task state, compaction"]
TURN["turn.go — per-turn state and cancellation"]
CMDS["commands.go — command dispatch"]
RUNTIME["runtime.go — Runtime, preamble, tool dispatch"]
CONFIG["agent_config.go — profile configuration"]
PROMPT["prompt_resolver.go — prompt resolution"]
end
subgraph Backend["cmd/olliesrv/internal/backend/"]
BACKEND["backend.go — Backend interface and streaming"]
PROVIDERS["openai, anthropic, ollama, gemini, copilot, kiro, codewhisperer"]
POOL["pool.go — backend pool"]
end
subgraph ToolClient["cmd/olliesrv/internal/toolclient/"]
CLIENT["9P toolsrv client"]
SPAWN["spawn.go — local/remote toolsrv lifecycle"]
end
subgraph Session["cmd/olliesrv/internal/session/"]
SESS["session.go, registry.go — sessions and agents"]
PERSIST["persist.go — session persistence"]
end
subgraph Fs["cmd/olliesrv/internal/fs/"]
ROOT["newroot.go — NewRoot, RestoreSessions"]
SPEC["spec.go — 9P namespace declaration"]
SUPPORT["support.go — namespace support"]
end
subgraph Toolsrv["cmd/toolsrv/internal/"]
SERVER["server — 9P tool server and proc handlers"]
REGISTRY["registry — per-agent loaded tools"]
EXEC["exec — tool process execution"]
SANDBOX["sandbox — native Landlock policy"]
end
Agent --> Backend
Agent --> ToolClient
Agent --> Session
ToolClient --> Toolsrv
Fs --> Session
Toolsrv --> REGISTRY
Toolsrv --> EXEC
EXEC --> SANDBOX
```
## The Agent Struct (`cmd/olliesrv/internal/agent/agent.go`)
`Agent` owns one agent identity inside a session. Its runtime is swappable: `/agent` loads a new profile, creates a fresh toolsrv connection, rebuilds the runtime, and clears history. The session remains the stable host.
```go
type Agent struct {
history *History
runtime *Runtime
name string
profile string
agentsDir string
currentAction atomic.Pointer[actionHandle]
fifo Fifo
Feed Feed
output EventHandler
sessionID string
id string
parentID string
cwd string
chatLog []byte
// synchronization, persistence, and observation state
}
func NewAgent(cfg AgentParams) *Agent
```
All output flows through the agent's `EventHandler` as `Event{Role, Name, Content, ResponseID, OutputFormat}` values. Events include `user`, `assistant`, `reasoning`, `call`, `tool`, `usage`, `state`, `limitretry`, `retry`, `maxsteps`, `error`, and `info`. Session-level events use a separate topic-based pubsub bus for 9P observers.
## Agent Construction (`cmd/olliesrv/internal/agent/agent.go`, `runtime.go`, `agent_config.go`)
### AgentConfig
The full construction config:
| Field | Purpose |
|---|---|
| `History` | Pre-existing session history to restore (nil = new) |
| `Runtime` | Pre-built *Runtime (tools, hooks, prompt, params) |
| `Profile` | Name of the active agent configuration profile |
| `ID` | Immutable agent ID (`uname`) |
| `Name` | Mutable display name |
| `AgentsDir` | Path to agent JSON directory |
| `SessionsDir` | Path to persisted session files |
| `SessionID` | Unique session identifier |
| `AgentID` | Immutable agent principal |
| `CWD` | Working directory for tools and prompt |
| `NewToolServer` | Factory for fresh `ToolsrvConn` on agent switch |
| `NewBackend` | Factory for backend by name |
| `Log` | Logger instance |
| `MaxSteps` | Override agent config's maxSteps |
| `ReadPlanStep` | Function to read next unchecked plan step |
| `ListHandlers` | Additional list handlers |
### `Runtime`
`Runtime` contains the active backend, authenticated 9P toolsrv connection, rendered preamble, tool metadata, executor closure, generation parameters, max-step limit, compaction model, resolved user prompt, and startup messages. `BuildRuntime` lists loaded tools, resolves agent and user prompts, injects dispatch flags into tool schemas, and routes calls through `ToolsrvConn.CallTool(ctx, ...)`.
```go
type Runtime struct {
Backend backend.Backend
ToolServer *toolclient.ToolsrvConn
Preamble *Preamble
Tools []backend.Tool
ToolMeta map[string]protocol.ToolInfo
Exec toolExecutor
GenParams backend.GenerationParams
MaxSteps int
CompactionModel string
UserPrompt string
Messages []string
}
```
### BuildRuntime
`BuildRuntime(cfg, runner, cwd, env)` wires everything together:
1. Lists all tools from the runner
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 runner)
5. Extracts parallelism and tier classifiers
6. Applies tool/executor restrictions if configured
7. Returns a `*Runtime` struct ready for `New`
### Agent construction
`NewAgent(AgentParams)` creates an agent with identity, session, profile, history, runtime, working directory, persistence callbacks, event handler state, chat-log state, and session environment. The configuration type on disk is `AgentConfig`; construction dependencies are carried separately by `AgentParams`.
## The Agent Loop (`cmd/olliesrv/internal/agent/loop.go`, `turn.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
```mermaid
flowchart TB
START["Start turn"]
GATE["Context gate:\nstrip cold material\nif over budget"]
BUILD["Build message history:\nsystem prompt + task state + hot tail"]
STREAM["Stream from backend\n(with retry logic)"]
CHECK{"Tool calls\nin response?"}
EXEC["Execute tool calls\n(serial or parallel)"]
UPDATE["Update state:\nappend messages + results"]
INFER["Infer TaskState\nupdates"]
SAFETY["Safety checks:\nerror limits, stall,\nreplan, max steps"]
COMPACT["Auto-compact\nif needed"]
DONE["Turn complete"]
START --> GATE --> BUILD --> STREAM --> CHECK
CHECK -- yes --> EXEC --> UPDATE --> INFER --> SAFETY --> COMPACT --> BUILD
CHECK -- no --> DONE
```
### Streaming & Retry
The streaming loop handles three failure modes:
| Failure | Detection | Recovery |
|---|---|---|
| Rate limit (429) | `*RateLimitError` | Retry using provider `Retry-After` when available, otherwise exponential backoff |
| Transient (5xx/network) | `*TransientError` | Retry with exponential backoff |
| Stream drop | Stream closes without completion | Retry |
| Context overflow | `*ContextOverflowError` | Compact and retry once |
| Tool unsupported | `*ToolUnsupportedError` | Return through normal turn error handling |
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 call from the tool metadata and dispatch flags.
2. Batch read-safe calls when the metadata permits parallel execution.
3. Execute calls through the runtime executor, with per-turn caching for eligible file reads.
4. Hard-limit model-facing tool output to 32 KiB.
5. Assign each result a hot, warm, or cold memory tier for context management.
Tool calls use the current turn context, so interrupting a turn cancels in-flight tool execution.
### 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 (`cmd/olliesrv/internal/agent/history.go`)
### Message History
`History` holds a flat `[]backend.Message` slice. No rolling window — grows until compaction.
**Checkpoint**: `History.Checkpoint(ts TaskState)` forks the session — returns a new History that inherits the given TaskState but starts with a clean message history. Used for narrow-context sub-agents that know what to do without inheriting parent message noise.
Token tracking per session:
- `TotalInputTokens`, `TotalOutputTokens`, `TotalRequests`
- `TotalCachedInputTokens`, `TotalCacheCreationTokens`
- Per-turn accumulators reset at turn start
- `LastTurnCostUSD`, `SessionCostUSD`
### Compaction
Triggered when estimated tokens reach 50% 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_*` |
1. The tool's `tier` metadata (`cold`, `warm`, or `hot`)
2. `hot` when no tier metadata is present
### 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 and lifecycle callbacks
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 (`cmd/olliesrv/internal/agent/commands.go`)
`HandleCommand` forwards slash-command text to the agent `ctl` file over 9P. The current command set is defined by the filesystem control handler; examples include profile/backend/model changes, compaction, context and usage inspection, session lifecycle, tool/skill listing, prompt injection, queue management, and working-directory changes. `Inject` queues an interruption for the running turn; `InjectRewrite` replaces the pending interruption.
### Agent Switching
When `/agent <name>` is issued:
1. Load the new agent config JSON
2. Create a fresh runner via `newToolServer()`
3. Call `BuildRuntime` with the new config
4. If the config specifies a backend/model, switch those too
5. Replace the entire `Runtime` pointer on the agent struct
6. Clear the session (fresh context for the new persona)
## Backend System (`cmd/olliesrv/internal/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
**GeminiBackend** (Gemini API):
- Google Gemini models
**CopilotBackend** (GitHub Copilot API):
- Copilot Chat integration
**CodeWhispererBackend** (AWS CodeWhisperer API):
- AWS CodeWhisperer integration
## Config System (`cmd/olliesrv/internal/agent/agent_config.go`)
### Agent Config Schema
The on-disk `AgentConfig` currently contains `prompt`, `userPrompts`, `backend`, `model`, `tools`, `autoLoad`, `maxSteps`, `compactionModel`, `systemPrompt`, and embedded backend `GenerationParams`. Tool and prompt resolution are performed while building the runtime.
### 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
Prompt assembly and resolution are documented in [`architecture-prompting.md`](architecture-prompting.md). The agent runtime consumes the resulting preamble and per-turn user prompt.
## Cost Tracking (`cmd/olliesrv/internal/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
Sandbox policy is owned and enforced by toolsrv. See [`architecture-toolsrv.md`](architecture-toolsrv.md#sandbox).
## Event System
The agent emits events through its `EventHandler`. Session-level observers use `session.PublishEvent`, `SubscribeEvents`, and topic wildcards. The filesystem layer converts session events into chat and blocking event files.
## 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**: agent state and chat changes signal 9P waiters through change channels and condition variables
- **Tool parallelism**: read-safe tools fan out within a turn when metadata permits; result caching and `/tmp/ollie/{sessionID}` locks coordinate execution
## Session ID Format
```
<unix-nanoseconds>-<6-hex-chars>
```
Lexicographically sortable by creation time. Unique even within the same nanosecond due to the random suffix.