# Zidane Architecture

## Agent Lifecycle

```mermaid
flowchart TB
    subgraph Creation ["createAgent()"]
        C1[Create hooks] --> C2[Merge config]
        C2 --> C3["Provider + Tools + Options"]
        C3 --> C4["eager: true?"]
        C4 -->|yes| C4B["Kick off warmup()\nin background"]
        C4 -->|no| C5[Return Agent]
        C4B --> C5
    end

    subgraph FirstRun ["First run() — lazy init"]
        L1["executionContext.spawn()"] --> L2["warmup()\nawait in-flight OR\nconnect MCP servers\nin parallel (Promise.all)"]
        L2 --> L3["resolveSkills()"]
        L3 --> L4["buildCatalog() → system prompt"]
    end

    subgraph RunSetup ["run() setup"]
        R1["Validate prompt\n(required unless session has turns)"] --> R2["session.startRun()"]
        R2 --> R3["Resolve behavior\n(maxTurns, maxTokens, thinkingBudget, cache)"]
        R3 --> R4["Build system prompt\n(agent system + skills catalog)"]
        R4 --> R5["Build tools\n(agent tools + MCP)"]
        R5 --> R6["Build turns\n(resume session + system + prompt?)"]
        R6 --> R7["runLoop()"]
    end

    subgraph PostRun ["Post-loop"]
        P1{"Aborted?"} -->|yes| P2["session.abortRun()"]
        P1 -->|no| P3["session.completeRun()"]
        P2 --> P4["agent:done hook"]
        P3 --> P4
        P4 --> P5["Return AgentStats"]
    end

    Creation --> FirstRun
    FirstRun --> RunSetup
    RunSetup --> PostRun

    style Creation fill:#1a1a2e,stroke:#e94560,color:#fff
    style FirstRun fill:#16213e,stroke:#0f3460,color:#fff
    style RunSetup fill:#1a1a2e,stroke:#e94560,color:#fff
    style PostRun fill:#16213e,stroke:#0f3460,color:#fff
```

## Turn Loop

```mermaid
flowchart TB
    START((runLoop)) --> ABORT1{Aborted?}
    ABORT1 -->|yes| DONE["agent:done hook\nReturn stats"]
    ABORT1 -->|no| TURN["executeTurn()"]

    TURN --> STATS["Accumulate usage"]
    STATS --> ABORT2{Aborted?}
    ABORT2 -->|yes| DONE

    ABORT2 -->|no| STEER{Steering\nqueued?}
    STEER -->|yes| INJECT_STEER["Inject steering\nas user turn"] --> CONTINUE
    STEER -->|no| ENDED{LLM said\ndone?}

    ENDED -->|no| CONTINUE((Next turn))
    CONTINUE --> ABORT1

    ENDED -->|yes| FOLLOWUP{Follow-up\nqueued?}
    FOLLOWUP -->|yes| INJECT_FU["Inject follow-up\nas user turn"] --> CONTINUE
    FOLLOWUP -->|no| DONE

    style DONE fill:#0f3460,stroke:#e94560,color:#fff
    style TURN fill:#1a1a2e,stroke:#533483,color:#fff
```

## Single Turn

```mermaid
flowchart TB
    T1["Generate turnId"] --> T2["turnsToMessages()"]
    T2 --> T2B["sanitizeStoredToolResults()\n(flatten images for non-vision providers)"]
    T2B --> T3["context:transform hook"]
    T3 --> T3A["applyPairingRepair() + ensureEndsWithUserMessage()\n(wire-only orphan tool_use / tool_result repair,\nuser-tail guard for prefill-rejecting models)"]
    T3A --> T3B["system:transform hook\n(per-request system mutation)"]
    T3B --> T3C["applyThinkingDecay()\n(per-turn budget taper)"]
    T3C --> T4["turn:before hook"]
    T4 --> T4A["applyPairingRepair() + ensureEndsWithUserMessage() — re-run\n(idempotent; seals last-mile messages mutation)"]
    T4A --> STREAM

    subgraph STREAM ["provider.stream()"]
        S1["Send system + messages + tools to LLM"] --> S2["Stream text chunks"]
        S2 --> S3["stream:text hook (per chunk)"]
    end

    STREAM -->|error| RETRY{"retryable +\nno output yet?"}
    RETRY -->|yes, attempts left| BACKOFF["stream:retry hook\nabortable backoff sleep"]
    BACKOFF --> STREAM
    RETRY -->|no| CATCH["stream:error hook (raw err)\nturn:after hook (zero usage)\nRethrow"]
    STREAM -->|ok| S4{"Has text?"}
    S4 -->|yes| S5["stream:end hook"]
    S4 -->|no| AFTER
    S5 --> AFTER["turn:after hook (with usage)"]

    AFTER --> DONE_CHECK{LLM done?\nNo tool calls}

    DONE_CHECK -->|yes| SCHEMA{"behavior\n.schema?"}
    SCHEMA -->|yes| ENFORCE["Force __output__ tool call\nExtract structured output\nFire output hook"]
    SCHEMA -->|no| PUSH_FINAL["Return ended: true"]
    ENFORCE --> PUSH_FINAL
    DONE_CHECK -->|no| TOOLS

    subgraph TOOLS ["Tool Execution"]
        direction TB
        TE1["Push assistant turn\n(with tool calls)"] --> TE2["executeToolBatch:\nWalk calls in submission order.\nSafe tools fan out as a fleet\n(cap = maxConcurrentTools);\nunsafe tools barrier (drain → run alone → unblock).\nAbort/steer between dispatches → synthesize\nInterrupt/Skipped results for unstarted calls.\nshell error cascades a sibling abort."]
        SEQ --> TE3["Push tool results turn\n+ fire tool-results:after"]
        PAR --> TE3
    end

    TOOLS --> RET["Return ended: false"]

    style STREAM fill:#16213e,stroke:#0f3460,color:#fff
    style TOOLS fill:#1a1a2e,stroke:#533483,color:#fff
```

## Tool Execution

```mermaid
flowchart TB
    T1["tool:gate hook\n(block | result | runToolCounts)"] --> BLOCKED{"block?\n(wins over result)"}
    BLOCKED -->|yes| T2["Return 'Blocked: reason'\n(does NOT increment runToolCounts)"]
    BLOCKED -->|no| RES{result?}
    RES -->|yes| RES1["increment runToolCounts\nfire tool:transform → tool:after\n(skip validate / tool:before / execute)"]
    RES1 --> RES2["Return substitute"]
    RES -->|no| INC["increment runToolCounts"]
    INC --> T3{Tool\nexists?}
    T3 -->|no| TU["tool:unknown hook\n(can substitute result\nor suppress error)"]
    TU --> TUE{suppressError?}
    TUE -->|no| T4["tool:error hook\n(can substitute result)"]
    TUE -->|yes| T4B["(skip tool:error)"]
    T4 --> TUR["Return ctx.result\n(or default 'Unknown tool: …')"]
    T4B --> TUR
    T3 -->|yes| T5["validateToolArgs()\n(auto-coerce types)"]
    T5 --> VALID{Valid?}
    VALID -->|no| VR["validation:reject hook\n(observational)"]
    VR --> T6["Return 'Validation error: …'"]
    VALID -->|yes| VC{"coercions\n.length > 0?"}
    VC -->|yes| VCH["validation:coerce hook\n(observational)"]
    VC -->|no| T7
    VCH --> T7["tool:before hook\n(ctx.coercions when present, runToolCounts)"]
    T7 --> T8["toolDef.execute(coercedInput, ctx)\nracing against perCallAbort.signal"]
    T8 -->|cancelled by\nagent.cancelTool| TC["tool:cancelled hook\n(reason, runToolCounts)"]
    TC --> TCR["Return TOOL_USE_CANCELLED_MESSAGE\n(isError: true; skips tool:transform/after)"]
    T8 -->|error| T9["tool:error hook\n(can substitute result)"]
    T8 -->|ok| T10["tool:transform hook\n(can mutate result;\nctx.outputBytes pre-mutation)"]
    T9 --> T10
    T10 --> T10B["stripImagesForNonVision()\n(replace image blocks with marker\nwhen provider.meta.capabilities.vision === false)"]
    T10B --> T11["tool:after hook\n(ctx.outputBytes post-mutation, runToolCounts)"]
    T11 --> T12["Return { id, content: string | ToolResultContent[] }"]

    style T8 fill:#1a1a2e,stroke:#e94560,color:#fff
    style TU fill:#1a1a2e,stroke:#0f3460,color:#fff
    style VR fill:#1a1a2e,stroke:#0f3460,color:#fff
    style RES1 fill:#1a1a2e,stroke:#533483,color:#fff
```

**`tool:gate` mutations.** Set `block` to refuse (`Blocked: reason`), `result` to substitute and skip execute, or neither to run normally. `block` wins over `result` so a policy gate always beats a consumer cache. The substitute path skips `tool:before` + validate + execute but fires `tool:transform` + `tool:after` — budgets and telemetry stay consistent with executed calls.

**Per-call cancellation.** Each dispatch registers an `AbortController` in `LoopContext.pendingToolCancels` keyed by `callId`; the tool body's `ctx.signal` is `AbortSignal.any([ctx.signal, perCallAbort.signal])`. `agent.cancelTool(callId, reason?)` flips the per-call signal, the body promise races against the cancellation, the loop emits `tool:cancelled`, and the wire result becomes `TOOL_USE_CANCELLED_MESSAGE` with `isError: true`. Other in-flight tools in the same batch keep running — distinct from `agent.abort()` (whole-run abort → `INTERRUPT_MESSAGE_FOR_TOOL_USE` at the batch layer) and `TOOL_USE_SKIPPED_MESSAGE` (steered out before dispatch). The map entry is removed in a `finally` so it only ever holds currently-dispatching calls; the body promise gets a no-op `.catch` so misbehaving tools that ignore the signal can't surface as unhandled rejections after the loop has moved on.

**`runToolCounts`** is a frozen pre-call snapshot, scoped to `runId`. Includes dedup + gate-`result` substitutes; excludes blocked calls. Resumed sessions reset the counter. Concurrent fleets see the same pre-batch snapshot — built-in budget/dedup middleware uses its own gate-time reservation counter so `behavior.toolBudgets` stays atomic across the fleet.

**Output shape.** `string | ToolResultContent[]`. Text tools return strings; multimodal tools (MCP browsers, screenshots) return `[{ type: 'text' }, { type: 'image', mediaType, data }]`. Providers with `capabilities.imageInToolResult: true` (Anthropic, OpenAI Codex) route arrays natively; OpenAI-compat emits a companion `user` message with `image_url` parts. Non-vision providers swap image blocks for a text marker before the hook fires.

**`outputBytes`** populates `tool:after`, `tool:transform`, `mcp:tool:after`, `mcp:tool:transform` — UTF-8 byte length for text, base64 char length for images. This is a **wire-payload-size proxy**, not a token count (vision encoders tokenize decoded pixels, geometry-dependent). Use it for byte-budget heuristics; defer to provider-side context management for token-accurate accounting. Reproduce via `toolOutputByteLength(content)`.

### Concurrency scheduler

`executeToolBatch` (`src/loop.ts`) walks the assistant turn's tool calls in submission order and decides per-call whether to fan out:

- **Per-tool flag.** Every `ToolDef.isConcurrencySafe` (`boolean | ((input) => boolean)`) declares the safety verdict. Default `false` — fail-closed. Resolved once per call before dispatch begins.
- **Fleet vs. barrier.** Safe calls join a running concurrent fleet up to `behavior.maxConcurrentTools` (default `10`). Unsafe calls (and any call when the fleet already contains an unsafe sibling or is at the cap) **barrier**: drain the in-flight fleet, run alone, then unblock the queue.
- **Order preserved at yield time.** Results are written to `results[i]` slots in submission order; concurrent completion order is invisible to the model.
- **Sibling abort controller.** One `AbortController` per batch carries both the parent abort (forwarded so a user-issued abort still tears in-flight tools down) and the `shell`-cascade signal. Cancelled siblings get a `Cancelled: a sibling \`shell\` call …` tool_result; non-shell errors are isolated.
- **Abort + steer between dispatches.** Re-checked after each in-iteration drain so a tool that aborts mid-batch doesn't dispatch one extra call. Unstarted slots are filled with `INTERRUPT_MESSAGE_FOR_TOOL_USE` / `TOOL_USE_SKIPPED_MESSAGE` (both `isError: true`) so the assistant turn's `tool_use` ids never go orphan.
- **`shell` is conditional.** `shell.isConcurrencySafe(input)` parses the command via `isReadOnlyShellCommand` — strict allow-list of read-only command bases (`ls`, `cat`, `git status`, `rg`, …) plus a `git` subcommand allow-list. Rejects pipes, redirects, command chains, and subshell expansions.

Forcing fully sequential dispatch (for deterministic debugging / eval-grade runs) is one knob: `behavior.maxConcurrentTools: 1`. Every call then barriers regardless of its `isConcurrencySafe` flag.

## Tool ergonomics defaults

Built-in tools are opinionated about output sizes — drop your v2 `tool:transform` truncation polyfills.

| Tool | Default behavior |
|---|---|
| `read_file` | Line-paginates: `offset=1`, `limit=2000`, `maxBytes=262144` (256 KiB). Footer documents paging (`re-read with offset=N+1`). Binary files (NUL byte / `0xFFFD`-heavy) return a marker. `limit: 0` / `maxBytes: 0` disable. With `behavior.readLineNumbers` (default on), each line gets a `<N>\t` prefix; `edit` strips it on match so the model can paste verbatim. |
| `shell` | Tail-priority truncation at `maxOutputBytes=32768` (32 KiB, combined stdout+stderr). Head trim marker: `…(N bytes truncated from head)…`. `0` disables. UTF-8 never splits mid-codepoint. Appends `(exit N, Nms)` footer + surfaces non-empty stderr by default (`metadata: false` opts out). |
| `write_file` | Reads existing content; returns `Created` / `Updated` / `No change needed: …` so the model detects no-ops without a separate read. Race window in shared docker/sandbox contexts documented and accepted. |
| `edit` | Fails clearly on non-unique `old_string` (unless `replace_all: true`). On not-found, includes a nearest-match preview so the model recovers without re-reading. |
| `multi_edit` | Sequential edits to one file. **Best-effort, per-hunk** — each step runs against the file as left by the previous APPLIED step; a per-step failure (`old_string` not found, ambiguous without `replace_all`, identical strings, malformed input) is reported in the result but doesn't block siblings. Writes the file iff at least one step applied. All-applied returns the legacy `Edited <path>: applied N edits (M replacements).` so the renderer's success-suppression keeps the diff alone; any failure emits `Edited <path>: applied N of M edits …` (or `multi_edit error: no edits applied to <path> (M attempted).` when nothing landed) plus per-failure lines + an `<edit-outcomes>` block. Approval is a host concern: the chat layer's `tool:gate` rebinds `ctx.input.edits` to the approved subset (rebind, not mutate — the model's original `tool_call` block in `session.turns` stays untouched), and a `tool:transform` hook merges the body's subset-keyed outcomes with the approval-side denied/skipped entries (`mergeApprovalAndBodyOutcomes`) before appending the canonical `<edit-outcomes>` block. `parseEditOutcomesFromResult` re-parses it on reload. See CHAT.md → Per-edit approval. |
| `grep` | Wraps `rg` when present (with explicit `.` path to avoid stdin hangs). `node:fs/promises` glob fallback otherwise (post-filters directories via `stat` — Bun's port doesn't yet support `withFileTypes`). `head_limit=250`, `offset` paginates. |

## Per-edit approval — harness purity

The harness has no notion of "partial approval"; per-hunk DENIAL lives entirely above the loop. `edit` / `write_file` are single-mode (one hunk, succeed-or-error). `multi_edit` is best-effort, per-hunk on the BODY side — it reports `applied` / `failed` per step in its result text and is the only edit tool whose body emits an `<edit-outcomes>` block on its own (when any step failed). The chat layer merges that body-side block with approval-side decisions before the canonical block lands on the wire.

Four loop-visible artifacts carry the decision through:

1. **Input rebind at `tool:gate`** — the host's gate handler (`src/tui/app.tsx`'s `applyGate`) computes the approved subset, then assigns a fresh shallow-clone to `ctx.input` whose `edits` array is filtered. The model's original `tool_call.input` in `session.turns` is never mutated (the rebind produces a new object); the tool body sees the smaller, all-approved batch and runs unchanged.
2. **Pending-annotation map** — keyed by `tool_call.id`, holds the 1:1 `EditOutcome[]` (over the model's ORIGINAL hunks). Lives in the host's React tree (`pendingAnnotationsRef` in the TUI).
3. **Body-side outcomes** — `multi_edit`'s best-effort body emits an `<edit-outcomes>` block in its result whenever any step failed, keyed against the approved SUBSET it actually saw (in subset-position order).
4. **`tool:transform` annotation merge** — the host strips the body's block (when present), merges it into the approval-side 1:1 outcomes via `mergeApprovalAndBodyOutcomes` (each approval `applied` placeholder is replaced by the body's next outcome), rewrites the body's header so subset-relative `N of M edits` becomes original-total (`rewriteMultiEditHeader`), and appends the canonical merged block. The merged outcomes also flow back into the in-flight `'tool'` event (`updateToolEventOutcomes`) so live diff badges reflect the post-merge truth without waiting for a session reload. Bubbles to `child:tool:transform` for subagent-issued calls via `BUBBLED_MUTABLE_EVENTS`.

**Wire format** (canonical shape for live emit + persisted replay):

```
Edited path/to/foo.ts: applied 2 edits (3 replacements).

<edit-outcomes>
#1 applied
#2 denied: denied by user
#3 applied
</edit-outcomes>
```

- Opening + closing tags each on their own line.
- One line per hunk: `#<1-based-index> <kind>[: <reason>]`.
- `kind ∈ {applied | denied | skipped | failed}`. `failed` originates in `multi_edit`'s body (per-step tool-level rejection); `denied` / `skipped` originate at the gate; `applied` is the resolved positive outcome.
- Block emitted ONLY when at least one hunk is NOT applied. All-applied calls fall through to the legacy summary alone.

A fully-denied call (every hunk rejected) skips the substitute path; the harness writes `Blocked: User denied this tool call` as the tool_result and the host emits a synthetic `tool-result` event with body `[fully denied] <edit-outcomes>…</edit-outcomes>` for live display only (persisted history stays terse).

Replay path: `parseEditOutcomesFromResult(text)` (from `zidane/chat`) recovers the `EditOutcome[]` from the annotation block. `eventsFromTurns` pairs `tool_call` ↔ `tool_result` by `callId` and re-attaches the outcomes onto the `'tool'` event so reloaded transcripts paint identical per-hunk badges.

## `StreamEvent.callId` pairing contract

`StreamEvent` carries `callId?: string` on `'tool'` / `'tool-result'` (and any tool-scoped `'error'`) events. The value is verbatim `tool_use.id` from the originating assistant turn's `tool_call` block. It's the canonical pairing key for out-of-process consumers (GUI, SDK, logger, replayer) — pairing by tool name + arrival order silently swaps results the moment two parallel calls share a tool name (`read_file`, `edit`, `multi_edit`).

Live emit stamps `callId` from `ToolHookContext.callId` (and from `McpToolHookContext.callId` for MCP tool results) on every `tool:before` / `tool:after` / `mcp:tool:after` / `child:tool:before` / `child:tool:after` synthesis, plus on the chat-layer gate's synthetic `'tool'` / `'tool-result'` emits (denied + partial paths). Replay reads `tool_call.id` / `tool_result.callId` straight off the persisted block, so reloaded transcripts pair identically to the live ones. The field stays absent for non-tool events and for session / provider-level error events that have no callId in scope.

## Tool argument auto-coercion

`validateToolArgs` runs between `tool:gate` and `tool:before`:

1. **Required-field presence** — missing/null required fields fail outright.
2. **Type-aware coercion** on top-level properties:
   - `"true"` / `"yes"` / `"1"` → `true`; `"false"` / `"no"` / `"0"` → `false`.
   - Numeric strings → numbers/integers.
   - JSON-encoded strings → arrays/objects.
   - Numbers/booleans → strings (when schema declares `string`).
3. Reject only when no coercion succeeds; fires `validation:reject` so consumers count broken-call attempts separately from runtime errors.

`tool:execute` always receives the coerced input.

## Data Flow

```mermaid
flowchart LR
    subgraph Input
        USER[User prompt] --> TURNS[SessionTurn array]
        SESSION[(Session store)] -.->|resume| TURNS
    end

    subgraph Agent
        TURNS --> MSG["turnsToMessages()"]
        MSG --> PROVIDER["provider.stream()"]
        PROVIDER --> RESULT{Tool calls?}
        RESULT -->|text only| DONE[Push final turn]
        RESULT -->|tool calls| EXEC["Execute tools"]
        EXEC --> PUSH["Push tool results"]
        PUSH --> MSG
    end

    subgraph Output
        DONE --> STATS["AgentStats"]
        DONE -.->|incremental| SESSION
        PUSH -.->|incremental| SESSION
    end

    style Input fill:#16213e,stroke:#0f3460,color:#fff
    style Agent fill:#1a1a2e,stroke:#533483,color:#fff
    style Output fill:#16213e,stroke:#0f3460,color:#fff
```

## Typed errors

Provider failures are wrapped before leaving `agent.run()`:

```
native error
  → provider.classifyError(err)   returns ClassifiedError | null
     kind: 'context_exceeded'  → AgentContextExceededError
     kind: 'provider_error'    → AgentProviderError
     kind: 'aborted'           → AgentAbortedError
     null                      → AgentProviderError (fallback)
```

`agent.abort()` and aborted `AbortSignal` always produce `AgentAbortedError`. Match on `instanceof`, not on `err.message`.

## Prompt caching

When `behavior.cache: true` (default), zidane injects `cache_control: { type: 'ephemeral' }` breakpoints on three stable prefixes:

1. **System prompt** — `cache_control` on the static prefix (see boundary marker below); single block when no marker is present.
2. **Tool definitions** — last tool in the array.
3. **Conversation** — last content block of the last message, skipping trailing `thinking` / `redacted_thinking` blocks that can't carry `cache_control`.

Three of Anthropic's four allowed breakpoints; headroom left for a future thinking-block breakpoint. Next turn, the longest matching prefix is served from cache; only the new suffix is billed at full input rate.

Provider semantics:

| Provider | Behavior |
|---|---|
| `anthropic` | Breakpoints honored natively. |
| `openrouter` | Breakpoints forwarded; Anthropic + Gemini routes honor them, others ignore. |
| `arcee` / `local` / `openaiCompat` | Factory-gated via `cacheBreakpoints: boolean` (default **off**). `openrouter` flips it on; Arcee/local/bare `openaiCompat` stay off so strict OpenAI-compatible endpoints don't reject unknown fields. |
| `cerebras` | Off (factory doesn't enable breakpoints). |
| `openai` (Codex) | Not affected — separate wire format (pi-ai). |

Cache-read + cache-write counts surface on `TurnUsage.cacheRead` / `TurnUsage.cacheCreation`,
populated from Anthropic's native fields and from OpenRouter's `prompt_tokens_details`.

### System-prompt boundary

Per-turn churn in the system prompt (e.g. `<env>` cwd/date rewrites) used to invalidate the entire cached prefix — `cache_control` on the last block covered the whole text. The `SYSTEM_PROMPT_BOUNDARY` marker splits it into two:

```
┌──────────────┐  cache_control: ephemeral
│ STATIC half  │  doctrine + skills catalog + tool catalog + user instructions
├──────────────┤  ← __ZIDANE_SYSTEM_PROMPT_BOUNDARY__
│ DYNAMIC half │  env (cwd / date / platform), IDE state, anything per-turn
└──────────────┘  (no cache_control)
```

The static prefix rides the prompt cache across turns/sessions; the dynamic suffix re-bills per turn. The marker is structural metadata — providers strip it from the wire bytes before the model sees the prompt.

**Public API** (`zidane`):

| Symbol | Purpose |
|---|---|
| `SYSTEM_PROMPT_BOUNDARY` | The literal marker. Embed it in your system prompt to opt in. |
| `splitSystemPrompt(s)` | Returns `{ static, dynamic }`. Marker-free input ⇒ everything is static. |
| `joinSystemPrompt(static, dynamic)` | Compose; marker omitted when `dynamic` is empty. |
| `appendStaticSection(s, extra)` | Append to the static half (used internally for skills + lazy tool catalogs). |
| `appendDynamicSection(s, extra)` | Append to the dynamic half (used by hosts for per-turn state). |
| `replaceDynamicSection(s, next)` | Replace the dynamic half wholesale (used by the TUI's env rewriter). |
| `renderSystemForWire(s)` | Strip the marker for providers that can't honor `cache_control`. |
| `hasSystemPromptBoundary(s)` | Boolean probe. |

The built-in profiles (`buildBuildSystem` / `buildPlanSystem` in `zidane/chat`) emit the boundary automatically when `cwd` is provided. Custom callers calling `createAgent({ system })` directly stay marker-free by default — zero overhead, identical wire bytes to before.

**Provider integration:**

- `anthropic` — `applyAnthropicCacheBreakpoints(params, originalSystem)` splits string system prompts on the marker. The OAuth path skips the split (the canonical CC prompt occupies `params.system`; the user doctrine lives in an injected user message).
- `openaiCompat` — `toOAIMessages` strips the marker; `applyOAICacheBreakpoints(messages, originalSystem)` splits the leading system message into static + dynamic content parts when caching is on.
- `openai` (Codex), `cerebras`, `arcee`, `local`, and any cache-disabled route — marker is collapsed via `renderSystemForWire` so it never reaches the model.

### Cache-break diagnosis

`installCacheBreakLogger(hooks, options?)` hashes per-request dimensions (`system`, `tools`, `model`, `thinking`, `thinkingBudget`) at `turn:before` and compares against the previous turn on `turn:after`. When `cacheRead` is zero on turn 2+ and dimensions differ, it logs the responsible dimension(s) — turning "cache broke" into "tools array changed at turn 7 because MCP server X loaded".

```ts
import { installCacheBreakLogger } from 'zidane'

const uninstall = installCacheBreakLogger(agent.hooks, {
  log: line => logger.debug(line),   // defaults to console.error
  verbose: !!process.env.ZIDANE_DEBUG?.includes('cache'),
})
```

Heuristic only: skips providers that don't report `cacheRead` (Codex / Cerebras). Pure helpers `snapshotCacheDimensions`, `diffCacheDimensions`, and `fnv1a32` are exported for custom telemetry that needs to stamp the same hashes onto spans.

## Tool aliasing (wire vs. canonical)

Aliasing lives at the LLM boundary only:

```
AgentOptions.tools keyed by canonical name ← agent dispatch
                 │
                 ▼
Loop.aliasMaps (canonical ↔ wire)
                 │
outbound: canonical → wire     inbound: wire → canonical
  │                              │
  ▼                              ▼
ToolSpec sent to provider      result.toolCalls / assistantMessage.content[tool_call]
(model calls by wire name)     (immediately rewritten to canonical)
                 │
                 ▼
session.turns persists canonical names
ToolHookContext.name = canonical, .displayName = wire
```

`session.turns` can't desync on alias-map changes; hooks match on canonical names; UI adapters read `displayName` for labels.

## Skills activation lifecycle

Zidane implements the [Agent Skills spec](https://agentskills.io/specification) via dedicated-tool activation. Three tools auto-inject when the catalog is non-empty: `skills_use` / `skills_read` / `skills_run_script`.

```
createAgent({ skills })                    → skillActivationState({ maxActive })

ensureSkillsResolved() [idempotent, shared promise across callers]:
  resolveSkills(config)                    → skills:resolve
  buildCatalog(skills, …)                  → skills:catalog

triggered by (first-caller-wins):
  agent.warmup()                           ← explicit pre-warm (or eager: true)
  agent.run()                              ← lazy bootstrap on first run
  agent.activateSkill(name)                ← host-driven activation (e.g. TUI slash-cmd)

first run():
  rehydrate from session turns             → skills:activate (via: 'resume')

per run():
  installAllowedToolsGate, installToolBudgetsGate, installDedupToolsGate
  auto-inject skills_use / skills_read / skills_run_script
  runLoop():
    skills_use({ name })                   → state.activate(skill, 'model')
                                             → skills:activate (via: 'model')
                                             → <skill_content> wrapper
    skills_use({ name, mode: 'deactivate' }) → state.deactivate(skill)
                                             → skills:deactivate (reason: 'model')
                                             → "Skill X deactivated" confirmation
    skills_read / skills_run_script        → gated on state.isActive + path sandbox
  run end: deactivateAllSkills()           → skills:deactivate (reason: 'run-end')

agent.activateSkill(name)                  → skills:activate (via: 'explicit')
agent.deactivateSkill(name)                → skills:deactivate (reason: 'explicit')
agent.reset()                              → skills:deactivate (reason: 'reset')

session-resume rehydration:
  last-mode-wins per skill across history's skills_use blocks
  → trailing mode:'deactivate' STAYS deactivated; activate/deactivate/activate ends active
```

**allowed-tools.** When any active skill declares `allowed-tools`, a `tool:gate` handler blocks calls outside the union (the three skills tools are always implicitly allowed). Block-list (`- entry`) and flow-list (`[a, b]`) authoring shapes both normalize to `string[]` via the frontmatter parser's array-aware path; `AgentToolNotAllowedError.message` carries a recovery hint pointing the model at `skills_use({ mode: 'deactivate', name })` to release the offending skill without waiting for a run boundary.

**Body-only delivery.** `skills_use` returns the body (frontmatter stripped) wrapped in `<skill_content>` for host-SDK context protection. Body includes shell-interpolated instructions (`` !`cmd` ``, fresh per activation), the skill directory, the resource listing (not eagerly loaded), and compatibility + allowed-tools when present.

**Source precedence.** `SkillConfig.source: 'project' | 'user' | 'inline' | 'builtin'`. Project beats user; first-found wins within scan order. `trustProjectSkills: false` drops project-scoped skills.

## Progressive tool disclosure

`behavior.toolDisclosure: 'lazy'` (default `'eager'`) replaces upfront MCP schemas with a name-only catalog and auto-injects a `tool_search` native tool. Native + skill tools always stay eager — only MCP is eligible. Per-server `McpServerConfig.disclosure: 'eager' | 'lazy'` overrides the agent-wide default.

```
per run() when toolDisclosure === 'lazy' and ≥1 MCP tool is lazy:

partitionToolDisclosure(toolsBySpecName, mcpToolNames, servers, mode, aliases)
  → lazyEntries (wire name + canonicalName + description + inputSchema + server?)
  → eagerCanonicalNames

initialUnlocked = new Set(eagerCanonicalNames)   // never mutated — phase-1 filter
unlocked        = new Set(eagerCanonicalNames)   // mutated as tools surface
dynamicUnlockOrder = []                          // append-only log

if (lazyEntries && toolSearch.tool !== false && !host-defined tool_search):
  tools.tool_search = createToolSearchTool({ catalog: lazyEntries, unlocked, addUnlock, defaultLimit })
  unlocked.add('tool_search')
  initialUnlocked.add('tool_search')   // pinned to the cached prefix

system += buildSearchableCatalog(lazyEntries)   // wire names, XML-escaped, no inputSchema
installLazyDisclosureGate                       // tool:gate refuses lazy ∉ unlocked

per turn — buildFormattedTools() emits in two phases:
  1. registry-order walk filtered by initialUnlocked  ← byte-stable cached prefix
  2. dynamicUnlockOrder, in unlock order              ← append-only tail

tool_search({ names | query | server, limit? }):
  for each match: unlocked.add(canonical); addUnlock(canonical)
                  (addUnlock is the framework-supplied recorder that
                   appends to dynamicUnlockOrder; legacy hosts that only
                   pass `unlocked` still work, just lose tail-ordering.)
  return <tool_search_results>…<input_schema>{json}</input_schema>…
    (inputSchema JSON inlined with `<` → `\u003c` so a hostile schema can't break the envelope)

run end: uninstallLazyDisclosureGate; per-run state GC-eligible on next run()
```

**Aliasing.** `LazyToolEntry.name` is the wire name (the only one the provider accepts). `LazyToolEntry.canonicalName` keys `unlocked` and dispatches `ctx.tools[name]`. `session.turns`, `ToolHookContext.name`, and `ctx.tools` keys stay canonical throughout.

**Hard gate vs. provider validation.** Production providers (Anthropic, OpenAI) refuse `tool_use` blocks for tools absent from the request — the gate is mostly redundant there. It exists for custom / mock / lenient providers and makes the lazy boundary observable via `tool:gate`. Refusal points at the discovery tool's wire name.

**Cache-stable injection.** Every successful `tool_search` invalidates only the SUFFIX of the wire tools array starting at the first new schema — the eager-tool + `tool_search` prefix never moves. Providers cache the prefix on the first turn and serve it from cache every subsequent turn regardless of how many discovery waves the model runs. Mid-array insertion would break the prefix; the two-phase emit above is what keeps it stable. The system-prompt catalog is byte-stable (alphabetical) so it rides the system-prompt cache cleanly.

**Host-defined `tool_search`.** A host tool named `tool_search` (or aliased to that wire name) shadows the auto-injection. The host implementation must call `createToolSearchTool({ catalog, unlocked, addUnlock? })` to drive the unlock flow — bare implementations leave the gate refusing every lazy call. Pass the same `addUnlock` callback the framework supplies (or a moral equivalent) to keep tail-ordering. For custom flows, prefer `toolSearch.tool: false`.

## Hook Firing Order

### Skills

```
skills:resolve                               ← once per agent, after discovery (warmup / run / activateSkill — first wins)
skills:catalog    [mutable: catalog]         ← once per agent, after system-prompt catalog built (same trigger)
skills:activate                              ← per activation; { skill, via: 'model' | 'explicit' | 'resume' }
skills:deactivate                            ← per deactivation; { skill, reason: 'run-end' | 'explicit' | 'reset' | 'model' }
```

Every run ends with an implicit deactivate-all pass (`reason: 'run-end'`); activation state never leaks across runs unless re-asserted via `agent.activateSkill()`.

### Per turn

All tool hooks carry `turnId` + `callId`. Typed via `ToolHookContext` / `McpToolHookContext`. Both expose `name` (canonical) and `displayName` (wire / aliased; defaults to `name`).

Hooks marked **[mutable]** have ctx fields that affect downstream behavior when handlers mutate them. Last-writer wins.

> **Ordering note:** `context:transform` fires **before** `turn:before` — "transform the context that will be sent". For per-turn observation, use `turn:before`. For editing messages just before the provider call, use `context:transform` — it lands upstream of the defensive pairing repair (`ensureToolResultPairing` + `ensureEndsWithUserMessage`) so a mutation can't strand orphan `tool_use` / `tool_result` blocks on the wire. `turn:before` is `[mutable: options]` too as a last-mile escape hatch (per-turn reminders, audit-tag wrappers); the loop re-runs both defenses after the hook so the same guarantee holds, at the cost of one extra walk over the message list. Downstream consumers building provider requests **outside** `agent.run()` should use `session.toMessages({ provider })` / `toWireMessages(turns, { provider })` — these apply the same repair so raw `session.turns` never reach a provider as the `tool_result must be preceded by a tool_call with the same toolCallId` 400.

```
context:transform     [mutable: messages]       ← edit messages before LLM call
system:transform      [mutable: system]         ← per-request system mutation
turn:before                                      ← turn starts
  oauth:refresh                                  ← (when provider supports it)
  stream:thinking (n) / stream:text (n)          ← each streamed chunk
  stream:end                                     ← text complete (if text present)
  stream:retry (n)                               ← retryable error caught; sleep then re-enter stream (only fires on intermediate failures)
  stream:error                                   ← provider rejected terminally (omitted on user-initiated aborts and on retries that recover)
turn:after                                       ← always fires (incl. errors); SessionTurn + toolCounts
  tool:gate           [mutable: block, reason, result?]  ← refuse / substitute / run
  tool:unknown        [mutable: result?, suppressError]  ← no toolDef registered
  validation:reject   / validation:coerce        ← arg validation outcomes
  tool:before                                    ← (input coerced; coercions? when present; priorContent? for write_file)
    mcp:tool:gate     [mutable: block, reason, result?]
    mcp:tool:before
    mcp:tool:transform [mutable: result]         ← + outputBytes pre-mutation
    mcp:tool:after                               ← + outputBytes post-mutation
  tool:transform      [mutable: result, isError] ← + outputBytes pre-mutation
  tool:after                                     ← + outputBytes post-mutation
  tool:error          [mutable: result?]         ← on execute throw; ctx.result substitutes
  tool:cancelled                                 ← agent.cancelTool(callId); skips transform/after; wire = TOOL_USE_CANCELLED_MESSAGE
tool-results:after                               ← after the tool-results user turn is pushed (persistence seam)
usage                                            ← running totals
output                                           ← when behavior.schema set
budget:exceeded / tool-budget:exceeded           ← byte / per-tool caps tripped
repeat-guard:exceeded                            ← consecutive-identical streak block/abort (behavior.repeatGuard)
```

**`tool-results:after`** is the durable-persistence seam. The assistant turn carrying `tool_use` blocks and the user turn carrying matching `tool_result` blocks must be persisted as a pair — otherwise a process death between turns leaves an orphan `tool_use` that Anthropic rejects on resume. Subscribe here to write the pair atomically. The scheduler also synthesizes `Interrupt: …` / `Skipped: …` tool_results for any unstarted calls when abort / steering interrupts a batch (and `synthesizeMissingToolResults` in `src/agent.ts` covers the `turn:after` throw path), so the assistant turn's `tool_use` ids always stay matched.

**Unknown-tool path.** When no `toolDef` matches the call name, the loop fires `tool:before` with `ctx.unknown: true` first (so UI consumers binding `tool:before` still see the model's attempt as a card), then `tool:unknown` (substitute / suppressError seam), then `tool:error` (unless `suppressError`), then `tool:dispatched` with `outcome: 'unknown'`. `tool:after` is NOT in the chain — the body never executed. The model receives either the consumer's substitute or `Tool error: Unknown tool: <name>`. `validation:reject` fires after coercion attempts fail.

### Per run

```
session:start                        ← run begins
session:turns                        ← seeded user turn persisted (fires before the first turn:before
                                       when run({ prompt }) seeds a user turn; skipped on promptless resume)
system:before                        ← per-run only when run({ system }) is set. Observational —
                                       the loop reads options.system directly; ctx mutations are dropped.
                                       Use system:transform for actual mutation.
  [turn hooks above, repeated]
  session:turns                      ← after turns persisted (includes SessionTurn[] of appended turns)
  steer:inject                       ← if steering/follow-up message injected
agent:abort                          ← agent.abort() called mid-run
agent:done                           ← run finished (all exit paths), includes stats.output
session:end                          ← run finished (completed | aborted | error)
                                       includes turnRange [start, end] for cleanup
```

### Background task lifecycle

Fires when the model invokes `shell({ run_in_background: true })`. The shell tool dispatches via `ExecutionContext.execBackground`, which spawns a process group, opens a `WriteStream` to `<userDir>/<sessionId>/tasks/<task-id>.<context-timestamp>.log` (the timestamp segment is `YYYYMMDD-HHMMSS-mmm` UTC, shared by every task in the same `ExecutionContext` so two contexts on the same session never collide), and returns immediately with a `TaskHandle`. The agent listens to `background:exit` and queues a `<task-notification>` block for injection into the next user turn (see "Notification queue" below).

**Host opt-in / opt-out.** Background mode is auto-disabled at the schema level (the `run_in_background` field is dropped from the `shell` tool's input schema and the related description paragraphs are stripped) when either `behavior.tasksDir` is unset or `behavior.disableBackgroundTasks: true` is set. `createAgent` performs the rewrite once per run as it indexes tools by spec name — only for the identity-equal framework `shell` constant, so host-customized shell-named tools are left untouched. The runtime check in `runBackground` stays as defense-in-depth (forged inputs that smuggle the flag in past the schema fall through to a clean error, not a silent fallthrough to foreground). Hosts that want explicit control should call `createShellTool({ allowBackground })` and register the tailored variant directly.

```
background:start     ← spawn succeeded; payload carries taskId + pid + outputPath
                       (observational; tools don't wait on listeners)
background:exit      ← at-most-once per task; the context's settle latch
                       guarantees no duplicate fires (close-or-error race-safe)
```

**Notification queue.** The agent owns a `Map<taskId, TaskExitInfo>` populated by the `background:exit` listener. At the start of every `agent.run()`, the queue drains and each entry becomes a leading `text` block on the seeded user turn (`<task-notification>…</task-notification>` XML — see `renderTaskNotificationXml` in `src/agent.ts`). The same agent listens to `tool:after` for `shell_kill` (deletes by `task_id` from input) and `read_file` / `read` (deletes by matching the `path` argument against any pending entry's `outputPath`) so the model never receives a redundant signal when it already learned about the exit through other means. The drain happens once per entry — `Map.set` overwrites by id, so a defensive double-enqueue can't multiply notifications.

**Replay synthesis.** `eventsFromTurns` detects the `<task-notification>` XML in persisted user-turn text blocks (anchored regex — no false positives mid-prompt) and emits a structured `'task-notification'` `StreamEvent` instead of the generic `user-prompt` event. The XML text block is dropped from the rendered stream so the banner doesn't double-paint alongside the raw XML. Field decoding is tolerant — every tag falls back to a safe default if the persisted block predates a later schema addition.

**Destroy ordering.** `agent.destroy()` runs cleanup INSIDE-the-session first (notification queue, per-call cancels) then NEEDED-by-the-session (MCP connection, execution handle, skills cache). The execution context's own `destroy(handle)` walks its background-task registry, SIGTERMs each survivor's process group, and only resolves once every output stream has flushed + closed. The reverse order would tear down the handle underneath still-flushing tasks and corrupt log files. The queue is re-cleared AFTER `executionContext.destroy()` because the `background:exit` hook listener stays bound for the agent's lifetime and may re-populate the map mid-teardown.

**Orphan reaper.** `ProcessContext` lazy-registers a `process.on('exit')` handler on the first `execBackground` call that synchronously SIGTERMs every still-running task's process group. Without it, the `detached: true` spawn flag (required for the kill-tree semantics) would leak children when the host process exits without calling `destroy()` (Ctrl+C, uncaught exception, OOM kill). The handler deregisters in `destroy()` so reconstructed contexts don't accumulate listeners.

### Once (lazy on first `warmup()` / `run()` / `activateSkill()` — or eager when `eager: true`)

```
mcp:bootstrap:start / mcp:bootstrap:end  ← per server, always fires (durationMs + ok/toolCount | error)
mcp:connect                              ← per server bootstrapped OK
mcp:error                                ← per server that failed (fatal: connect/transport)
mcp:warn                                 ← per server diagnostic line (non-fatal; currently: stdio child stderr, one event per line)
skills:resolve                           ← after discovery
skills:catalog  [mutable: catalog]       ← after catalog build
skills:activate                          ← during run, when a skill file is read
```

`warmup()` fans MCP connect + skills resolution out in parallel; both sides are idempotent and share an in-flight promise across concurrent callers. Hooks registered after `warmup()` resolves may miss `skills:resolve` / `skills:catalog` — register before any `warmup()` / `run()` / `activateSkill()` call (or before `createAgent` returns when using `eager: true`). Use `mcp:bootstrap:end` for cold-start attribution — not wall-clock diffs against `mcp:connect`, which is skipped on failure.

### Spawn lifecycle

Fires when a parent invokes the `spawn` tool. Children inherit the parent's preset (tools, system, aliases, mcpServers, skills, behavior) by default — see SKILL.md "Sub-agent Spawning" to restrict.

```
spawn:before          ← child agent created, before run
spawn:complete        ← child finished successfully, includes AgentStats
spawn:error           ← child run threw
```

The child's lifecycle also bubbles to the parent hook surface with `childId` + `depth`, so nested UI renders without subscribing on the child:

```
child:stream:text / child:stream:thinking / child:stream:end / child:stream:error
child:tool:gate / child:mcp:tool:gate          ← share the child's ctx — parent mutations propagate
child:tool:cancelled                           ← bubbled per-call cancel from a subagent's tool
child:tool:transform                           ← share the child's ctx — parent mutations propagate
child:tool:before / child:tool:after / child:tool:error
child:turn:after
```

`BUBBLED_MUTABLE_EVENTS` (`src/tools/spawn.ts`) is the canonical list: `tool:gate`, `mcp:tool:gate`, `tool:transform`. The bubbled ctx is the same reference the child loop awaits on, so a parent listener can refuse / substitute / annotate a subagent's tool call without registering on the child agent. The chat layer's per-edit annotation flow registers on both `tool:transform` and `child:tool:transform` so subagent-issued `multi_edit` calls also get `<edit-outcomes>` blocks appended to their results.

**Subagent type registry** (`createSpawnTool({ subagents })`). When set, the spawn tool's input schema gains a `subagent_type` enum (the registered keys plus the always-available `'general-purpose'` fallback). The matching `SubagentDef` overlays the parent's resolved config for that one dispatch:

- `system` — preset system prompt. Per-call `input.system` still wins (model specialization beats preset defaults).
- `tools: readonly string[]` — restrict the child to this canonical-name allow-list (strict subset of the parent's tools; unknown names silently dropped). Matched on `tool.spec.name`, not registry key.
- `readonly: true` — convenience for `tools: ['read_file', 'grep', 'glob', 'list_files']`. `tools` wins when both are set.
- `description` — short blurb rendered into the schema field so the model can pick a type without round-tripping through docs.

Unknown `subagent_type` values are routed to `'general-purpose'` (no overlay) — Claude-Code-trained models emitting `subagent_type: 'Verification'` against hosts that didn't wire that type still run, just with the parent's defaults. Empty / unset registry omits the `subagent_type` schema field entirely (historical schema).

## Dependency Graph

```mermaid
flowchart TB
    AGENT[agent.ts] --> LOOP[loop.ts]
    AGENT --> TOOLS[tools/]
    AGENT --> PROVIDER[providers/]
    AGENT --> SESSION[session/]
    AGENT --> SKILLS[skills/]
    AGENT --> MCP[mcp/]
    AGENT --> CONTEXTS[contexts/]
    AGENT --> PROMPT[prompt.ts]
    AGENT --> ALIASING[aliasing.ts]

    LOOP --> PROVIDER
    LOOP --> TOOLS
    LOOP --> ALIASING
    LOOP --> ERRORS[errors.ts]

    PRESETS[presets/] --> TOOLS
    TOOLS --> CONTEXTS
    TOOLS -->|spawn| AGENT

    PROVIDER --> TYPES[types.ts]
    PROVIDER --> ERRORS
    LOOP --> TYPES
    SESSION --> TYPES
    AGENT --> TYPES

    TRACING[tracing.ts] -.->|installs on| AGENT

    style AGENT fill:#e94560,stroke:#fff,color:#fff
    style LOOP fill:#533483,stroke:#fff,color:#fff
    style TYPES fill:#0f3460,stroke:#fff,color:#fff
    style ERRORS fill:#16213e,stroke:#e94560,color:#fff
    style ALIASING fill:#16213e,stroke:#e94560,color:#fff
    style PROMPT fill:#16213e,stroke:#e94560,color:#fff
    style TRACING fill:#1a1a2e,stroke:#533483,color:#fff
```

## Presets

A `Preset` is `Omit<Partial<AgentOptions>, 'provider' | 'execution' | 'session' | 'mcpConnector'>` — a reusable slice of agent config that consumers spread into `createAgent`:

```ts
createAgent({ ...basic, provider })
```

Excluded fields are either ambient runtime (`provider`, `session`, `execution`) or test seams (`mcpConnector`). Everything else — `name`, `system`, `tools`, `toolAliases`, `behavior`, `mcpServers`, `skills`, `eager`, **`hooks`** — is preset-shareable.

### `hooks` field

`AgentOptions.hooks` (typed `AgentHookMap`) is registered for the **lifetime of the agent**: identical in effect to calling `agent.hooks.hook(event, fn)` immediately after `createAgent` returns. Handlers fire across every `run()`. Unknown event names throw at construction (typo guard). For per-run handlers that auto-detach at run end, use `AgentRunOptions.hooks` instead — same shape, different scope.

Each entry accepts a single handler or an array. The array form is what `composePresets()` emits when multiple presets target the same event.

### `composePresets(...presets)` — field-aware merge

Bare `...spread` is shallow: `{ ...a, ...b }` overwrites every key `b` defines, including `hooks`. Two presets that both register a `turn:before` handler would silently lose `a`'s handler. `composePresets` is the explicit composition primitive:

```ts
createAgent({ ...composePresets(basic, telemetry, mine), provider })
```

Merge rules:

| field                                    | rule                                                  |
| ---------------------------------------- | ----------------------------------------------------- |
| `name`, `system`, `eager`, `skills`      | last-defined wins                                     |
| `tools`, `toolAliases`, `behavior`       | shallow-merge (later keys override per field)         |
| `mcpServers`                             | concat with last-wins on `name` collision (a later preset can override an earlier preset's `github` server) |
| `hooks`                                  | per-event concat — every handler fires, in preset order |

`hooks` always emerges as `event → handler[]` so `createAgent` sees a uniform shape regardless of whether the source was a single handler or an array. Handler order within an event follows preset order: earlier presets register first.

### When to reach for what

- **One preset, no overlap with consumer code** → spread directly: `createAgent({ ...preset, provider })`.
- **Multiple presets** → `composePresets(...)` first. Spread alone gives you last-wins on `hooks` and `behavior` field-overrides you probably didn't intend.
- **Dynamic registration after creation** → `agent.hooks.hook(event, fn)` (returns an `unregister` fn). Preset hooks are "baked in"; runtime hooks are dynamic.
- **Per-run, auto-detached** → `agent.run({ hooks: { … } })`. Same `event → fn | fn[]` shape.

## Option Precedence

`run.behavior` > `agent.behavior` > defaults (field-by-field merge).

Defaults: unlimited turns (set `maxTurns` as a safety net), `maxTokens: 16384`, level-based thinking; `cache: true`, `dedupReads: true`, `requireReadBeforeEdit: false`, `maxConcurrentTools: 10`, `compactStrategy: 'off'`, `compactThreshold: 128 KiB`, `compactKeepTurns: 4`, `toolDisclosure: 'eager'`, `readLineNumbers: true`, `elideStaleReads: false`, `surfaceMcpInstructions: true`, `maxConsecutivePauseTurns: 5`, `persistTurns: true` (incremental per-turn store sync; set `false` to persist once at run end instead — trades mid-run crash granularity for O(1) writes per run on stores whose `appendTurns` re-serializes the session). All other knobs (`toolOutputBudget`, `dedupTools`, `toolBudgets`, `repeatGuard`, `thinkingDecay`, `toolSearch`, `persistThreshold`, `persistDir`, `persistExcludeTools`, `persistMaxBytes`, `maxCostUsd`, `maxTotalTokens`, `retry`) are `undefined` (`retry` defaults to 3 attempts with 1s → 30s exponential backoff when absent). The chat-layer profiles (`BUILD_AGENT` / `PLAN_AGENT`) override several of these (including turning `requireReadBeforeEdit` **on** and enabling `repeatGuard`) — see CHAT.md → Agents.

**`toolOutputBudget`** (off by default). The loop sums every tool result's `outputBytes` after `tool:transform`, so consumer truncation counts. On overshoot, a synthetic user message is appended:

> `[Tool output budget exceeded: N bytes returned in this turn (cap: M). Summarize the salient findings before calling more tools.]`

`budget:exceeded` fires with `{ turn, turnId, bytes, budget }`. The next turn picks up the message as latest input — model self-compacts instead of being cut.

**`compactStrategy: 'tail' | CompactFunction`** — client-side companion for non-Anthropic providers. `'tail'` sums tool-result bytes after `sanitizeStoredToolResults` and before `context:transform`; on overshoot, `tool_result` blocks older than `compactKeepTurns` are replaced with a stub. Text and image blocks pass through; `session.turns` is untouched. Anthropic users should prefer the server-side `context-management-2025-06-27` beta (token-accurate) via `anthropic({ extraBetas, contextManagement })`. Pluggable form: pass a `(messages, { threshold, keepTurns, totalBytes }) => messages | Promise<messages>` for LLM-summary or custom semantic compaction — same wire-only contract, must preserve `tool_use ↔ tool_result` adjacency and the trailing user message. Throws degrade gracefully (logged via `console.error`, original payload survives).

**`dedupReads` + `requireReadBeforeEdit`** share the same per-session `WeakMap<Session, Map<canonicalAbsPath, { contentHash, offset, limit, maxBytes, mtimeMs }>>`. The key is the canonical absolute path (via `node:path.resolve(handle.cwd, modelProvidedPath)`) so all path shapes the model can write — `src/x`, `./src/x`, `/abs/cwd/src/x` — converge on one entry. Map population happens whenever **either** feature is enabled; the dedup *short-circuit* (returning the "unchanged since the previous read" stub) is what `dedupReads` actually gates. This decoupling means `dedupReads: false` no longer breaks the `requireReadBeforeEdit` guard. `edit` / `multi_edit` reject when no entry exists or the on-disk hash drifted; both update the hash on success so chained edits don't need re-reads. Optional `AgentOptions.readState` (also exposed on `ToolContext.readState`) forwards an explicit map — used by `createSpawnTool({ shareReadState: true })` so a sessionless child still feeds the parent's tracking without sharing turn persistence. GC'd with the session when no explicit map is in play.

**`dedupTools`** generalizes the same pattern via a consumer hasher per tool. Parallel `WeakMap<Session, Map<toolName, { hash, result, repeats }>>` keeps only the most recent hash. Two forms:

- **Hasher function** (legacy / `mode: 'replay'`) — on hit, the prior result replays verbatim. Substitute path fires `tool:transform` + `tool:after` so budgets stay coherent. Makes loops cheap, doesn't break them.
- **Config object with `mode: 'block-after'`** — `{ hasher, mode: 'block-after', threshold, reason?, scope? }`. Replays for the first `threshold − 1` identical calls; the `threshold`-th identical call (and all subsequent ones) gets refused via `tool:gate` `ctx.block = true` with `Blocked: <reason>`. Counter is sticky on the same hash — once the cap trips it stays tripped until a different input resets the counter. `threshold` defaults to `4`, clamps at `2`. `reason` accepts a string or `(input, count) => string`; default is `"Identical \`<tool>\` call repeated N times — break the loop by changing your approach or moving on to a different step."`.
- **`scope: 'session' | 'run'`** (any mode) — keying granularity for the dedup counter. `'session'` (default) keys `(session, toolName)` — right for pure tools (`read_file`, `execute_sql`). `'run'` keys `(session, runId, toolName)` so a parent and its subagents never share an entry; `todowrite` uses this so its identical-write block is run-local. Falls back to session-scope for hand-built contexts with no `runId`.

Tools with side effects or non-deterministic outputs MUST NOT be listed (no safety net beyond the consumer hasher).

**`toolBudgets`** caps per-tool calls per run. When `runToolCounts[name] >= max`, fires `onExceed`: `'block'` refuses with `Blocked: <message>`, `'steer'` enqueues a synthetic user message (one per tool per run, deduped). Function form returns `{ mode, message }` dynamically. Fires `tool-budget:exceeded` (distinct from byte-level `budget:exceeded`). Counts include dedup substitutes by design.

**`thinkingDecay`** tapers `thinkingBudget` per run-relative turn. `applyThinkingDecay(base, decay, turn)` runs at `runTurn` start. Struct form `{ afterTurn, factor, floor }` does geometric decay; function form `(turn, base) => number` accepts arbitrary curves. Result clamped to `[0, base]` so buggy curves can't exceed the caller's opt-in.

**Gate precedence**. Consumer hooks register first (agent-lifetime then per-run), framework gates install after — `allowed-tools → tool-budgets → dedup → lazy-disclosure`. Each framework gate short-circuits on existing `ctx.block` or `ctx.result`, so the firing order is consumer → framework with the framework gates acting as an early-exit chain. The net effect: a policy gate always beats a consumer cache substitute, budgets enforce before dedup replays a cached call against an exhausted cap, and lazy-disclosure runs last so skill refusal wins over "load schema first" and dedup substitutes only hit on previously-recorded successful calls (which already passed lazy-disclosure once).

**Tool-result persistence** (`behavior.persistThreshold` + `persistDir` + `persistExcludeTools`). When set, the loop replaces oversize `tool_result` outputs with a `<persisted-output tool="…" bytes="…" path="…">` stub carrying a 2 KiB preview. Substitution happens just after `tool:transform`; the stub flows into `session.turns` directly so the prompt-cache prefix stays stable across turns. `persistDir` is required to enable the feature — without a target dir the framework silently skips persistence. `persistExcludeTools` (default empty at the SDK; the chat layer ships `DEFAULT_PERSIST_EXCLUDE_TOOLS` — see CHAT.md) lists canonical tool names that bypass regardless of size. `persistMaxBytes` caps the directory's cumulative `*.txt` size; after each successful write the helper evicts oldest-first by mtime (LRU) until total ≤ cap. The just-written blob is never evicted in the same sweep, so a single oversize result still lands. Off by default.

**Run-level budget circuit breaker** (`behavior.maxCostUsd` + `behavior.maxTotalTokens`). Soft caps the cumulative spend / token consumption of a single `agent.run()`. Checked **after** each turn (post-`turn:after` + `usage` hooks), so the breaching turn's numbers are visible before the throw lands. Trips throw `AgentBudgetExceededError` (`limit: 'cost' | 'tokens'`, `limitValue`, `actualValue`); the agent finalizes the session run as `'aborted'` so the persisted record distinguishes operator-imposed stops from runtime errors. Cost check wins when both axes would trip on the same turn (operators set ceilings against dollar accounting). Token ledger sums `input + output` only — cache reads/creates are billed at a discount and counting them at par would under-estimate affordability. `0` / negative / non-finite values disable the corresponding axis. Off by default; `maxTurns` is the count-based companion.

**`maxConsecutivePauseTurns`** caps consecutive empty `pause_turn` recoveries (Anthropic 4.6+ streaming edge case where the model returns a `pause_turn` stop reason with no text and no tool calls). Each empty pause counts; any productive turn — text, thinking, OR tool calls — resets the counter. When the counter reaches the cap, the loop exits cleanly (no `agent:abort` fire — this is graceful termination, not user-driven cancel). The last `TurnUsage.finishReason` stays `'pause'` so consumers detect pause exhaustion from `stats`. Default `5`; set `0` to disable.

**Retry-with-backoff** (`behavior.retry`: `{ maxAttempts, initialDelayMs, maxDelayMs }`). Wraps each `provider.stream()` call. Triggers only when `Provider.classifyError` returns `{ kind: 'provider_error', retryable: true }` (429, 5xx, Anthropic `overloaded_error` / `api_error` / `timeout_error` SSE events, OpenAI-compat stream truncation, OpenAI Codex transient messages) **and** nothing has streamed yet this turn — the partial-output guard avoids replaying content the user already saw; prefill-continuation (replaying with the partial assistant response prefilled so the model continues from there) is a separate, larger change. Fires `stream:retry` before each backoff sleep with `{ attempt, nextAttempt, delayMs, err, statusCode?, requestId? }`; `stream:error` only fires on the terminal failure. Backoff is `min(maxDelayMs, initialDelayMs * 2^(attempt-1))` with full jitter (`Math.random() * computed`); `retry-after` / `retry-after-ms` headers, when present on the error, override the schedule (still capped at `maxDelayMs`). The sleep is `signal`-aware so `agent.abort()` cancels cleanly. Defaults: `maxAttempts: 3`, `initialDelayMs: 1000`, `maxDelayMs: 30_000`. Set `maxAttempts: 1` to disable. The Anthropic SDK's own pre-stream retry (default `maxRetries: 2` for the initial HTTP request) sits below this layer; the loop-level retry covers mid-stream errors the SDK can't replay.

## Related

- `docs/SKILL.md` — public API surface (createAgent, providers, presets, hooks, sessions, skills, MCP).
- `docs/CHAT.md` — renderer-agnostic chat engine (`zidane/chat`).
- `docs/TUI.md` — OpenTUI terminal shell (`zidane/tui`).
- `docs/INTERACTIONS.md` — interactive tools (`present_plan`, `ask_user`) protocol.
- `docs/RESTATE.md` — optional durable-execution adapter (`zidane/restate`).
