ollie/doc/architecture-core.md

457 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 struct, identity, events"]
LOOP["loop.go — main agent loop, streaming"]
TURN["turn.go — turn orchestration, Submit"]
DISPATCH["dispatch.go — tool execution, batching"]
HISTORY["history.go — message history, usage"]
COMPACT["compact.go — compaction, cold summarization"]
CACHE["cache.go — tool result caching"]
RETRY["retry.go — error tracking, retry logic"]
STATE["state.go, chat.go, peer.go, subagent.go"]
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"]
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
ToolRevision uint64
Exec toolExecutor
GenParams backend.GenerationParams
MaxSteps int
CompactionModel string
UserPrompt string
Messages []string
}
```
### BuildRuntime
`BuildRuntime(cfg, runner, cwd, env)` wires everything together:
1. Lists the agent’s currently loaded tools from the runner and records the agent-scoped registry revision
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/`)
The agent package is organized by concern:
| File | Responsibility |
|------|---------------|
| `loop.go` | Main loop: stream LLM, execute tools, update history |
| `turn.go` | Submit entry point, turn orchestration |
| `dispatch.go` | Tool execution, batching, conflict detection |
| `history.go` | Message history, usage tracking |
| `compact.go` | Context compaction, cold summarization |
| `cache.go` | Tool result caching with file staleness |
| `retry.go` | Error tracking, transient retry logic |
| `state.go` | State management, signals, WaitChange |
| `chat.go` | Chat log, streaming output |
| `peer.go` | Peer agent management |
| `subagent.go` | Sub-agent depth tracking |
The `run()` function in `loop.go` is the core execution engine. It 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. Tool schemas are
loaded progressively: the default profile starts with `client_9p`, and semantic
tool hints tell the agent to issue `tool_load <name>` through its agent `ctl`
file.
After a tool round, the agent compares the toolsrv agent-scoped registry
revision with `Runtime.ToolRevision`. If the revision changed, it lists the
loaded tools, rebuilds backend schemas and metadata, and updates the rendered
tool preamble before the next model request.
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`, `compact.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
- **Peer messaging**: writing to `peer/{name}` triggers an async `Submit()` on the target agent; delivery is fire-and-forget within the session
## Session ID Format
```
<unix-nanoseconds>-<6-hex-chars>
```
Lexicographically sortable by creation time. Unique even within the same nanosecond due to the random suffix.