457 lines
18 KiB
Markdown
457 lines
18 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 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.
|