diff --git a/9p b/9p index 06a3e0f..e7dd398 160000 --- a/9p +++ b/9p @@ -1 +1 @@ -Subproject commit 06a3e0f5031c99a85d3088b7393f7a700b0a259c +Subproject commit e7dd3987efcafeca91740d8e5c664a83343257c9 diff --git a/agents/taskmanager.json b/agents/taskmanager.json index cdd95b5..d37e0e4 100644 --- a/agents/taskmanager.json +++ b/agents/taskmanager.json @@ -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": [ diff --git a/core b/core index 82ac183..9d4818a 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 82ac183357358b7a00b408a940e57ec1218a0c31 +Subproject commit 9d4818af8da4ce238181aa83bd9ed0332260f109 diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 7355747..e294093 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -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. - +## 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: " + 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=` | +| `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: +ARTIFACTS: +``` + +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 - └── "" → 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=`, 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: -``` - -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. - -`!` 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/.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. diff --git a/doc/CORE.md b/doc/CORE.md new file mode 100644 index 0000000..e27dc40 --- /dev/null +++ b/doc/CORE.md @@ -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: " + 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 | +|---|---| +| `!` | Shell command in session CWD | +| `/backend ` | Switch LLM backend | +| `/model ` | Switch model | +| `/agent ` | 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 ` | Inject into running turn | +| `/irw ` | Inject-rewrite | + +### Agent Switching + +When `/agent ` 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 `` 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 + +``` +-<6-hex-chars> +``` + +Lexicographically sortable by creation time. Unique even within the same nanosecond due to the random suffix. diff --git a/webui b/webui index 0ca3d38..c61a674 160000 --- a/webui +++ b/webui @@ -1 +1 @@ -Subproject commit 0ca3d38ec8cc2bf91cf02432ea0aa3205262293a +Subproject commit c61a674289a51c63fd6001880470ea96fafba2ef