zidane 7.0.14 → 7.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +18 -9
  2. package/dist/{acp-CZpX91YE.js → acp-Bw2b_kka.js} +3 -3
  3. package/dist/{acp-CZpX91YE.js.map → acp-Bw2b_kka.js.map} +1 -1
  4. package/dist/acp-cli.js +4 -4
  5. package/dist/acp.d.ts +1 -1
  6. package/dist/acp.js +1 -1
  7. package/dist/{agent-B1jsFaDq.js → agent-B_uQo4f1.js} +649 -48
  8. package/dist/agent-B_uQo4f1.js.map +1 -0
  9. package/dist/agent.d.ts +1 -1
  10. package/dist/agent.js +1 -1
  11. package/dist/{anthropic-Da39cE-d.js → anthropic-QbHqqTkV.js} +2 -2
  12. package/dist/{anthropic-Da39cE-d.js.map → anthropic-QbHqqTkV.js.map} +1 -1
  13. package/dist/{approvals-S100xf1d.js → approvals-B9aIQJRQ.js} +287 -45
  14. package/dist/approvals-B9aIQJRQ.js.map +1 -0
  15. package/dist/artifacts-CV2hUKnL.js +112 -0
  16. package/dist/artifacts-CV2hUKnL.js.map +1 -0
  17. package/dist/{auth-DfEWnkC7.js → auth-plw6npda.js} +19 -7
  18. package/dist/auth-plw6npda.js.map +1 -0
  19. package/dist/chat/pure.d.ts +3 -3
  20. package/dist/chat/pure.js +1 -1
  21. package/dist/chat.d.ts +6 -6
  22. package/dist/chat.js +5 -5
  23. package/dist/contexts/daytona.d.ts +1 -1
  24. package/dist/contexts/e2b.d.ts +1 -1
  25. package/dist/daemon-cli.js +1 -1
  26. package/dist/eval.d.ts +1 -1
  27. package/dist/eval.js +1 -1
  28. package/dist/extensions.d.ts +2 -2
  29. package/dist/extensions.js +2 -2
  30. package/dist/headless.d.ts +1 -1
  31. package/dist/headless.js +2 -2
  32. package/dist/{index-DFGECPfm.d.ts → index-Dc_zYpEu.d.ts} +351 -2
  33. package/dist/index-Dc_zYpEu.d.ts.map +1 -0
  34. package/dist/{index-DP7xYXr9.d.ts → index-Df2CH6bn.d.ts} +92 -7
  35. package/dist/index-Df2CH6bn.d.ts.map +1 -0
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +7 -6
  38. package/dist/{ipc-BLLscoIl.js → ipc-T_pPnHkp.js} +2 -2
  39. package/dist/{ipc-BLLscoIl.js.map → ipc-T_pPnHkp.js.map} +1 -1
  40. package/dist/ipc.d.ts +54 -6
  41. package/dist/ipc.d.ts.map +1 -1
  42. package/dist/ipc.js +3 -3
  43. package/dist/mcp.d.ts +1 -1
  44. package/dist/{oauth-C4GvY_p1.js → oauth-EIBUuSDK.js} +2 -2
  45. package/dist/{oauth-C4GvY_p1.js.map → oauth-EIBUuSDK.js.map} +1 -1
  46. package/dist/output/stream-json.d.ts +1 -1
  47. package/dist/output/terminal.d.ts +1 -1
  48. package/dist/plugins.d.ts +1 -1
  49. package/dist/presets.d.ts +2 -2
  50. package/dist/presets.js +2 -2
  51. package/dist/providers/anthropic.d.ts +1 -1
  52. package/dist/providers/anthropic.js +1 -1
  53. package/dist/providers/arcee.d.ts +1 -1
  54. package/dist/providers/baseten.d.ts +1 -1
  55. package/dist/providers/cerebras.d.ts +1 -1
  56. package/dist/providers/local.d.ts +1 -1
  57. package/dist/providers/openai-compat.d.ts +1 -1
  58. package/dist/providers/openai.d.ts +1 -1
  59. package/dist/providers/openai.js +1 -1
  60. package/dist/providers/openrouter.d.ts +1 -1
  61. package/dist/providers/xai.d.ts +1 -1
  62. package/dist/providers/xai.js +2 -2
  63. package/dist/{providers-BYgyPb15.js → providers-CoK1rZ1p.js} +2 -2
  64. package/dist/{providers-BYgyPb15.js.map → providers-CoK1rZ1p.js.map} +1 -1
  65. package/dist/providers.d.ts +1 -1
  66. package/dist/providers.js +3 -3
  67. package/dist/restate.d.ts +1 -1
  68. package/dist/restate.js +1 -1
  69. package/dist/{server-C8gULJ0d.js → server-DXM8ZbGi.js} +64 -6
  70. package/dist/server-DXM8ZbGi.js.map +1 -0
  71. package/dist/session/sqlite.d.ts +1 -1
  72. package/dist/session/sqlite.d.ts.map +1 -1
  73. package/dist/session/sqlite.js +117 -1
  74. package/dist/session/sqlite.js.map +1 -1
  75. package/dist/{session-K3icbPH9.js → session-DPZnncaQ.js} +60 -1
  76. package/dist/session-DPZnncaQ.js.map +1 -0
  77. package/dist/session.d.ts +2 -2
  78. package/dist/session.js +3 -2
  79. package/dist/skills.d.ts +1 -1
  80. package/dist/{tool-formatters-C-s_-u0v.d.ts → tool-formatters-BgdOYAy9.d.ts} +2 -2
  81. package/dist/{tool-formatters-C-s_-u0v.d.ts.map → tool-formatters-BgdOYAy9.d.ts.map} +1 -1
  82. package/dist/tools/fetch-url.d.ts +1 -1
  83. package/dist/tools/web-search.d.ts +1 -1
  84. package/dist/tools.d.ts +2 -2
  85. package/dist/tools.js +2 -2
  86. package/dist/{transcript-anchors-D1ToF-EW.d.ts → transcript-anchors-CtnwuQJM.d.ts} +3 -3
  87. package/dist/transcript-anchors-CtnwuQJM.d.ts.map +1 -0
  88. package/dist/tui.d.ts +3 -3
  89. package/dist/tui.d.ts.map +1 -1
  90. package/dist/tui.js +576 -102
  91. package/dist/tui.js.map +1 -1
  92. package/dist/{turn-operations-CZLsNgw-.js → turn-operations-DkJ1R0p8.js} +4 -3
  93. package/dist/turn-operations-DkJ1R0p8.js.map +1 -0
  94. package/dist/{turn-operations-Bp5-BAki.d.ts → turn-operations-khAvr7KJ.d.ts} +5 -3
  95. package/dist/turn-operations-khAvr7KJ.d.ts.map +1 -0
  96. package/dist/types.d.ts +1 -1
  97. package/dist/{xai-DILdFGzN.js → xai-CazjStOK.js} +3 -3
  98. package/dist/{xai-DILdFGzN.js.map → xai-CazjStOK.js.map} +1 -1
  99. package/docs/ACP.md +6 -0
  100. package/docs/ARCHITECTURE.md +179 -356
  101. package/docs/ARTIFACTS.md +130 -0
  102. package/docs/CHAT.md +426 -1312
  103. package/docs/EXECUTION_CONTEXT.md +46 -19
  104. package/docs/EXTENSIONS.md +234 -584
  105. package/docs/HEADLESS.md +5 -0
  106. package/docs/INTERACTIONS.md +21 -22
  107. package/docs/IPC.md +318 -335
  108. package/docs/PLUGINS.md +2 -0
  109. package/docs/README.md +57 -0
  110. package/docs/RESTATE.md +199 -491
  111. package/docs/RUN_IN_BACKGROUND.md +107 -558
  112. package/docs/SKILL.md +171 -238
  113. package/docs/TUI.md +213 -591
  114. package/package.json +1 -1
  115. package/dist/agent-B1jsFaDq.js.map +0 -1
  116. package/dist/approvals-S100xf1d.js.map +0 -1
  117. package/dist/auth-DfEWnkC7.js.map +0 -1
  118. package/dist/index-DFGECPfm.d.ts.map +0 -1
  119. package/dist/index-DP7xYXr9.d.ts.map +0 -1
  120. package/dist/server-C8gULJ0d.js.map +0 -1
  121. package/dist/session-K3icbPH9.js.map +0 -1
  122. package/dist/transcript-anchors-D1ToF-EW.d.ts.map +0 -1
  123. package/dist/turn-operations-Bp5-BAki.d.ts.map +0 -1
  124. package/dist/turn-operations-CZLsNgw-.js.map +0 -1
@@ -1,5 +1,9 @@
1
1
  # Zidane Architecture
2
2
 
3
+ Internal execution model of the agent loop: how a run boots, how a turn streams, how a tool call is gated/executed/transformed, and the hook contracts that hold it together. Read this when you're modifying `src/agent.ts` / `src/loop.ts`, writing a hook that depends on firing order, or diagnosing a prompt-cache break. For the public API surface (createAgent, presets, hooks, sessions) see `docs/SKILL.md`.
4
+
5
+ Contents: [Agent Lifecycle](#agent-lifecycle) · [Turn Loop](#turn-loop) · [Single Turn](#single-turn) · [Tool Execution](#tool-execution) · [Tool ergonomics defaults](#tool-ergonomics-defaults) · [Prompt caching](#prompt-caching) · [Tool aliasing](#tool-aliasing-wire-vs-canonical) · [Skills activation lifecycle](#skills-activation-lifecycle) · [Progressive tool disclosure](#progressive-tool-disclosure) · [Hook Firing Order](#hook-firing-order) · [Dependency Graph](#dependency-graph) · [Presets](#presets) · [Option Precedence](#option-precedence)
6
+
3
7
  ## Agent Lifecycle
4
8
 
5
9
  ```mermaid
@@ -7,51 +11,48 @@ flowchart TB
7
11
  subgraph Creation ["createAgent()"]
8
12
  C1[Create hooks] --> C2[Merge config]
9
13
  C2 --> C3["Provider + Tools + Options"]
10
- C3 --> C4["eager: true?"]
14
+ C3 --> C4["eager: true AND\n(MCP servers or skills configured)?"]
11
15
  C4 -->|yes| C4B["Kick off warmup()\nin background"]
12
16
  C4 -->|no| C5[Return Agent]
13
17
  C4B --> C5
14
18
  end
15
19
 
16
- subgraph FirstRun ["First run() — lazy init"]
17
- L1["executionContext.spawn()"] --> L2["warmup()\nawait in-flight OR\nconnect MCP servers\nin parallel (Promise.all)"]
18
- L2 --> L3["resolveSkills()"]
19
- L3 --> L4["buildCatalog() → system prompt"]
20
- end
21
-
22
20
  subgraph RunSetup ["run() setup"]
23
- R1["Validate prompt\n(required unless session has turns)"] --> R2["session.startRun()"]
24
- R2 --> R3["Resolve behavior\n(maxTurns, maxTokens, thinkingBudget, cache)"]
25
- R3 --> R4["Build system prompt\n(agent system + skills catalog)"]
26
- R4 --> R5["Build tools\n(agent tools + MCP)"]
27
- R5 --> R6["Build turns\n(resume session + system + prompt?)"]
28
- R6 --> R7["runLoop()"]
21
+ R1["Resolve model fallback chain\nValidate prompt / resume preconditions"] --> R2["Resolve behavior\n(maxTurns, maxTokens, thinkingBudget, cache)"]
22
+ R2 --> R3["session.startRun() → session:start hook"]
23
+ R3 --> R4["ensureExecutionHandle()\n(spawn execution context, idempotent)"]
24
+ R4 --> R5["warmup(): Promise.all(\n connect MCP servers,\n resolveSkills() → buildCatalog())\nidempotent, shared promise"]
25
+ R5 --> R6["Build tool aliases + formatted tool specs"]
26
+ R6 --> R7["Build system prompt\n(agent system + skills catalog)"]
27
+ R7 --> R8["Build turns (resume + seed prompt)\n→ session:turns hook"]
28
+ R8 --> R9["runLoop()"]
29
29
  end
30
30
 
31
31
  subgraph PostRun ["Post-loop"]
32
32
  P1{"Aborted?"} -->|yes| P2["session.abortRun()"]
33
33
  P1 -->|no| P3["session.completeRun()"]
34
- P2 --> P4["agent:done hook"]
34
+ P2 --> P4["finalizeSession() → session:end hook"]
35
35
  P3 --> P4
36
- P4 --> P5["Return AgentStats"]
36
+ P4 --> P5["agent:done hook"]
37
+ P5 --> P6["Return AgentStats"]
37
38
  end
38
39
 
39
- Creation --> FirstRun
40
- FirstRun --> RunSetup
40
+ Creation --> RunSetup
41
41
  RunSetup --> PostRun
42
42
 
43
43
  style Creation fill:#1a1a2e,stroke:#e94560,color:#fff
44
- style FirstRun fill:#16213e,stroke:#0f3460,color:#fff
45
- style RunSetup fill:#1a1a2e,stroke:#e94560,color:#fff
44
+ style RunSetup fill:#16213e,stroke:#0f3460,color:#fff
46
45
  style PostRun fill:#16213e,stroke:#0f3460,color:#fff
47
46
  ```
48
47
 
48
+ `ensureExecutionHandle()` and `warmup()` do real work only on the first `run()` / `warmup()` / `activateSkill()` call — both are idempotent and memoize on a shared in-flight promise, so every later call in the process fast-paths through.
49
+
49
50
  ## Turn Loop
50
51
 
51
52
  ```mermaid
52
53
  flowchart TB
53
54
  START((runLoop)) --> ABORT1{Aborted?}
54
- ABORT1 -->|yes| DONE["agent:done hook\nReturn stats"]
55
+ ABORT1 -->|yes| DONE["agent:abort hook (if not already fired)\nReturn stats to agent.run()"]
55
56
  ABORT1 -->|no| TURN["executeTurn()"]
56
57
 
57
58
  TURN --> STATS["Accumulate usage"]
@@ -73,6 +74,8 @@ flowchart TB
73
74
  style TURN fill:#1a1a2e,stroke:#533483,color:#fff
74
75
  ```
75
76
 
77
+ `runLoop` never fires `agent:done` — that's `agent.run()`'s job after the loop returns (see Agent Lifecycle above). The loop fires `agent:abort` itself at the two abort checks shown; `agent.run()`'s own catch block fires it a third way for a mid-stream throw, and the two firing sites are mutually exclusive per run.
78
+
76
79
  ## Single Turn
77
80
 
78
81
  ```mermaid
@@ -112,8 +115,7 @@ flowchart TB
112
115
  subgraph TOOLS ["Tool Execution"]
113
116
  direction TB
114
117
  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."]
115
- SEQ --> TE3["Push tool results turn\n+ fire tool-results:after"]
116
- PAR --> TE3
118
+ TE2 --> TE3["Push tool results turn\n+ fire tool-results:after"]
117
119
  end
118
120
 
119
121
  TOOLS --> RET["Return ended: false"]
@@ -129,7 +131,7 @@ flowchart TB
129
131
  T1["tool:gate hook\n(block | result | runToolCounts)"] --> BLOCKED{"block?\n(wins over result)"}
130
132
  BLOCKED -->|yes| T2["Return 'Blocked: reason'\n(does NOT increment runToolCounts)"]
131
133
  BLOCKED -->|no| RES{result?}
132
- RES -->|yes| RES1["increment runToolCounts\nfire tool:transform → tool:after\n(skip validate / tool:before / execute)"]
134
+ RES -->|yes| RES1["increment runToolCounts\nfire tool:transform → lift artifacts → tool:after\n(skip validate / tool:before / execute)"]
133
135
  RES1 --> RES2["Return substitute"]
134
136
  RES -->|no| INC["increment runToolCounts"]
135
137
  INC --> T3{Tool\nexists?}
@@ -153,7 +155,8 @@ flowchart TB
153
155
  T8 -->|error| T9["tool:error hook\n(can substitute result)"]
154
156
  T8 -->|ok| T10["tool:transform hook\n(can mutate result;\nctx.outputBytes pre-mutation)"]
155
157
  T9 --> T10
156
- T10 --> T10B["stripImagesForNonVision()\n(replace image blocks with marker\nwhen provider.meta.capabilities.vision === false)"]
158
+ T10 --> T10A["recordArtifactsFromMetadata()\nlift ctx.metadata.artifacts → session.addArtifact\n→ fire artifact:recorded per artifact"]
159
+ T10A --> T10B["stripImagesForNonVision()\n(replace image blocks with marker\nwhen provider.meta.capabilities.vision === false)"]
157
160
  T10B --> T11["tool:after hook\n(ctx.outputBytes post-mutation, runToolCounts)"]
158
161
  T11 --> T12["Return { id, content: string | ToolResultContent[] }"]
159
162
 
@@ -163,94 +166,55 @@ flowchart TB
163
166
  style RES1 fill:#1a1a2e,stroke:#533483,color:#fff
164
167
  ```
165
168
 
166
- **`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.
167
-
168
- **Per-call cancellation.** Each dispatch registers an `AbortController` in `LoopContext.toolCancels` (a `ToolCancelRegistry`, see `src/tool-cancel.ts`) keyed by `callId`; the tool body's `ctx.signal` is the run signal composed with the per-call signal via manual `linkAbortSignal` listeners (NOT `AbortSignal.any`, which would accumulate dependent-signal registrations on the long-lived run signal until GC — the composed signal is disposed in a `finally`). `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`. A cancel that arrives before the call dispatches (the host saw the id in the streamed turn) is parked in a bounded FIFO and applied at registration rather than dropped; the agent clears parked kills at each run start. 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 live 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.
169
-
170
- **`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.
171
-
172
- **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.
173
-
174
- **`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)`.
169
+ - **`tool:gate` mutations.** Set `block` to refuse (`Blocked: reason`), `result` to substitute and skip execute, or neither to run normally. `block` wins over `result`. The substitute path skips `tool:before` + validate + execute but still fires `tool:transform` → artifact lift → `tool:after`, so budgets and telemetry stay consistent with executed calls.
170
+ - **Artifact lift.** Any `tool:transform` handler that appends `SessionArtifactInput[]` to `ctx.metadata.artifacts` gets it normalized and stored by `recordArtifactsFromMetadata` (`src/loop.ts`) right after `tool:transform`, before `tool:after`. Best-effort: a store failure never fails the tool call. See `docs/ARTIFACTS.md`.
171
+ - **Per-call cancellation.** Each dispatch registers an `AbortController` in `LoopContext.toolCancels` keyed by `callId` (`src/tool-cancel.ts`). `agent.cancelTool(callId, reason?)` flips it, the loop fires `tool:cancelled`, and the wire result becomes `TOOL_USE_CANCELLED_MESSAGE` with `isError: true`. Other in-flight tools in the batch keep running. A cancel that arrives before the call dispatches is parked in a bounded FIFO and applied at registration instead of dropped.
172
+ - **Distinct interrupt paths.** `agent.abort()` (whole-run) → `INTERRUPT_MESSAGE_FOR_TOOL_USE` at the batch layer. Steered-out-before-dispatch → `TOOL_USE_SKIPPED_MESSAGE`. Per-call cancel → `TOOL_USE_CANCELLED_MESSAGE`. All three are `isError: true`.
173
+ - **`runToolCounts`** is a frozen pre-call snapshot, scoped to `runId`. Includes dedup + gate-`result` substitutes; excludes blocked calls. Resumed sessions reset the counter.
174
+ - **Output shape.** `string | ToolResultContent[]`. 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 `tool:transform` fires.
175
+ - **`outputBytes`** populates `tool:after` / `tool:transform` / `mcp:tool:after` / `mcp:tool:transform` — UTF-8 byte length for text, base64 char length for images. It's a wire-payload-size proxy, not a token count. Reproduce via `toolOutputByteLength(content)`.
175
176
 
176
177
  ### Concurrency scheduler
177
178
 
178
179
  `executeToolBatch` (`src/loop.ts`) walks the assistant turn's tool calls in submission order and decides per-call whether to fan out:
179
180
 
180
- - **Per-tool flag.** Every `ToolDef.isConcurrencySafe` (`boolean | ((input) => boolean)`) declares the safety verdict. Default `false` — fail-closed. Resolved once per call before dispatch begins.
181
- - **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.
182
- - **Order preserved at yield time.** Results are written to `results[i]` slots in submission order; concurrent completion order is invisible to the model.
183
- - **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.
184
- - **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.
185
- - **`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.
181
+ - **Per-tool flag.** `ToolDef.isConcurrencySafe` (`boolean | ((input) => boolean)`) declares the safety verdict, resolved once per call before dispatch. Default `false` — fail-closed.
182
+ - **Fleet vs. barrier.** Safe calls join a running fleet up to `behavior.maxConcurrentTools` (default `10`). Unsafe calls — or any call once the fleet has an unsafe sibling or is at cap — barrier: drain in-flight, run alone, unblock the queue.
183
+ - **Order preserved at yield time.** Results land in `results[i]` by submission order regardless of completion order.
184
+ - **Sibling abort.** One `AbortController` per batch carries the parent abort plus a `shell`-cascade signal: a `shell` error cancels siblings with a `Cancelled: a sibling \`shell\` call …` result; non-shell errors stay isolated.
185
+ - **Abort + steer re-checked** after each drain, so a mid-batch abort doesn't dispatch one extra call. Unstarted slots get `INTERRUPT_MESSAGE_FOR_TOOL_USE` / `TOOL_USE_SKIPPED_MESSAGE`.
186
+ - **`shell` is conditional** — `isReadOnlyShellCommand` allow-lists read-only bases (`ls`, `cat`, `git status`, `rg`, …) plus a `git` subcommand allow-list; pipes, redirects, chains, and subshells are rejected.
186
187
 
187
- 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.
188
+ Force fully sequential dispatch (deterministic debugging, eval-grade runs) with `behavior.maxConcurrentTools: 1` — every call then barriers regardless of `isConcurrencySafe`.
188
189
 
189
190
  ## Tool ergonomics defaults
190
191
 
191
- Built-in tools are opinionated about output sizes — drop your v2 `tool:transform` truncation polyfills.
192
+ Built-in tools are opinionated about output sizes — drop your own `tool:transform` truncation polyfills.
192
193
 
193
194
  | Tool | Default behavior |
194
195
  |---|---|
195
- | `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. |
196
- | `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). |
197
- | `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. |
198
- | `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. |
199
- | `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. |
200
- | `grep` | Wraps `rg` when present (with explicit `.` path to avoid stdin hangs). `find`-via-`exec` fallback otherwise. Skips git-ignored files by default (`gitignore` arg, default `true`): `rg` natively, fallback by intersecting with `git ls-files`; `gitignore:false` adds `rg --no-ignore` / keeps the full `find` listing. `head_limit=250`, `offset` paginates. |
201
- | `glob` | Default path intersects the pattern with `git ls-files --cached --others --exclude-standard`, so git-ignored trees (`node_modules`, build dirs) are skipped and no filesystem walk runs (`gitignore` arg, default `true`). Fallback (gitignore off / non-repo / `../`-escaping pattern) is a native scanner — `Bun.Glob().scan()` under Bun (`node:fs/promises.glob` buffers the whole walk and hangs), `fs.glob` under Node — recovering via a limit/signal-aware portable walk if the scanner throws on a pathological tree. 20 s tool-level timeout backstop. |
202
-
203
- ## Per-edit approval — harness purity
204
-
205
- 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.
206
-
207
- Four loop-visible artifacts carry the decision through:
208
-
209
- 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.
210
- 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).
211
- 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).
212
- 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`.
213
-
214
- **Wire format** (canonical shape for live emit + persisted replay):
215
-
216
- ```
217
- Edited path/to/foo.ts: applied 2 edits (3 replacements).
218
-
219
- <edit-outcomes>
220
- #1 applied
221
- #2 denied: denied by user
222
- #3 applied
223
- </edit-outcomes>
224
- ```
225
-
226
- - Opening + closing tags each on their own line.
227
- - One line per hunk: `#<1-based-index> <kind>[: <reason>]`.
228
- - `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.
229
- - Block emitted ONLY when at least one hunk is NOT applied. All-applied calls fall through to the legacy summary alone.
230
-
231
- 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).
232
-
233
- 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.
196
+ | `read_file` | `offset=1`, `limit=2000` lines, `maxBytes=262144` (256 KiB). `limit:0`/`maxBytes:0` disable. Binary content returns a marker. `readLineNumbers` (default on) prefixes each line `<N>\t`; `edit` strips it on match. |
197
+ | `shell` | Tail-priority truncation, `maxOutputBytes=32768` (32 KiB combined stdout+stderr), `0` disables. Appends `(exit N, Nms)` footer + non-empty stderr by default (`metadata:false` opts out). |
198
+ | `write_file` | Reads existing content first; returns `Created` / `Updated` / `No change needed: …` so the model detects no-ops without a separate read. |
199
+ | `edit` | Fails clearly on non-unique `old_string` unless `replace_all: true`. Not-found includes a nearest-match preview. |
200
+ | `multi_edit` | Sequential, best-effort per-hunk edits to one file; a per-step failure doesn't block siblings. Approval and the `<edit-outcomes>` annotation contract are the chat layer's — see `docs/CHAT.md` § Per-edit approval. |
201
+ | `grep` | Wraps `rg` when present, `find`-via-`exec` fallback otherwise. Skips git-ignored files by default (`gitignore`, default `true`). `head_limit=250`, `offset` paginates. |
202
+ | `glob` | Default path intersects the pattern with `git ls-files` so ignored trees are skipped (`gitignore`, default `true`). Falls back to a native scanner otherwise. 20s tool-level timeout. |
203
+ | `record_artifact` | `artifacts` preset tool (`src/tools/record-artifact.ts`); explicit path for artifacts detection can't see (background-shell PRs, deployments, issues). Input `{ kind, uri, label?, meta? }`, idempotent per `(kind, uri)`. See `docs/ARTIFACTS.md` § Producers. |
234
204
 
235
205
  ## `StreamEvent.callId` pairing contract
236
206
 
237
- `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`).
238
-
239
- 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.
207
+ `StreamEvent` carries `callId?: string` on `'tool'` / `'tool-result'` (and tool-scoped `'error'`) events — verbatim `tool_use.id` from the originating assistant turn. 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 it from `ToolHookContext.callId`; replay reads `tool_call.id` / `tool_result.callId` straight off the persisted block, so reloaded transcripts pair identically to live ones. Absent on non-tool events and on session/provider-level errors with no callId in scope.
240
208
 
241
209
  ## Tool argument auto-coercion
242
210
 
243
211
  `validateToolArgs` runs between `tool:gate` and `tool:before`:
244
212
 
245
- 1. **Required-field presence** — missing/null required fields fail outright.
246
- 2. **Type-aware coercion** on top-level properties:
247
- - `"true"` / `"yes"` / `"1"` → `true`; `"false"` / `"no"` / `"0"` → `false`.
248
- - Numeric strings → numbers/integers.
249
- - JSON-encoded strings → arrays/objects.
250
- - Numbers/booleans → strings (when schema declares `string`).
213
+ 1. Required-field presence — missing/null required fields fail outright.
214
+ 2. Type-aware coercion on top-level properties: `"true"`/`"yes"`/`"1"` → `true` (and the `false` equivalents); numeric strings → numbers; JSON-encoded strings → arrays/objects; numbers/booleans → strings when the schema declares `string`.
251
215
  3. Reject only when no coercion succeeds; fires `validation:reject` so consumers count broken-call attempts separately from runtime errors.
252
216
 
253
- `tool:execute` always receives the coerced input.
217
+ `toolDef.execute` always receives the coerced input.
254
218
 
255
219
  ## Data Flow
256
220
 
@@ -295,35 +259,26 @@ native error
295
259
  null → AgentProviderError (fallback)
296
260
  ```
297
261
 
298
- `agent.abort()` and aborted `AbortSignal` always produce `AgentAbortedError`. Match on `instanceof`, not on `err.message`.
262
+ `agent.abort()` and an aborted `AbortSignal` always produce `AgentAbortedError`. Match on `instanceof`, not on `err.message`.
299
263
 
300
264
  ## Prompt caching
301
265
 
302
- When `behavior.cache: true` (default), zidane injects `cache_control: { type: 'ephemeral' }` breakpoints on three stable prefixes:
303
-
304
- 1. **System prompt** — `cache_control` on the static prefix (see boundary marker below); single block when no marker is present.
305
- 2. **Tool definitions** — last tool in the array.
306
- 3. **Conversation** — last content block of the last message, skipping trailing `thinking` / `redacted_thinking` blocks that can't carry `cache_control`.
307
-
308
- 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.
309
-
310
- Provider semantics:
266
+ When `behavior.cache: true` (default), zidane injects `cache_control: { type: 'ephemeral' }` on three stable prefixes: the system prompt's static half, the last tool definition, and the last cacheable content block of the last message. That's 3 of Anthropic's 4 allowed breakpoints, leaving headroom for a future thinking-block breakpoint. Next turn, the longest matching prefix is served from cache; only the new suffix bills at full input rate.
311
267
 
312
268
  | Provider | Behavior |
313
269
  |---|---|
314
270
  | `anthropic` | Breakpoints honored natively. |
315
- | `anthropic({ bedrock })` | Same breakpoints, sent through `@anthropic-ai/bedrock-sdk` (SigV4; auth via the AWS credential chain, no Anthropic key). Bedrock accepts `cache_control` for supported Claude models (live-verified: creation on run 1, reads on run 2). Beta headers are not sent, so `cacheTtl: '1h'` degrades to the default TTL; `countTokens` returns `null` (no endpoint). Thinking uses the legacy `thinking: { type: 'enabled', budget_tokens }` shape — Bedrock's translation layer 400s the 4.6 surface (`output_config.effort`, adaptive), so effort tiers degrade to fixed budgets (`BEDROCK_THINKING_BUDGETS`), clamped strictly under the model's output ceiling (Anthropic requires `budget_tokens < max_tokens`; live-verified 400 otherwise). `nativeWebSearch` is NOT advertised — AWS doesn't host Anthropic's server-side search (live-verified 400), so a `web_search` ToolDef rides as a client-side function tool. Region: `bedrock.region` → `AWS_REGION` → `us-west-2`. Live coverage: `test-inference/bedrock.inference.test.ts`; anti-stall guarantees are deterministic in `test/bedrock.test.ts`. |
271
+ | `anthropic({ bedrock })` | Same breakpoints over `@anthropic-ai/bedrock-sdk`. No beta headers, so `cacheTtl: '1h'` degrades to the default TTL and `countTokens` returns `null`. Thinking uses the legacy `budget_tokens` shape, clamped under the model's output ceiling. `nativeWebSearch` is not advertised. |
316
272
  | `openrouter` | Breakpoints forwarded; Anthropic + Gemini routes honor them, others ignore. |
317
- | `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. |
318
- | `cerebras` | Off (factory doesn't enable breakpoints). |
319
- | `openai` (Codex) | Not affected — separate wire format (pi-ai). |
273
+ | `arcee` / `local` / `openaiCompat` | Factory-gated `cacheBreakpoints: boolean`, default off. `openrouter` flips it on. |
274
+ | `cerebras` | Off. |
275
+ | `openai` (Codex) | Not affected — separate wire format. |
320
276
 
321
- Cache-read + cache-write counts surface on `TurnUsage.cacheRead` / `TurnUsage.cacheCreation`,
322
- populated from Anthropic's native fields and from OpenRouter's `prompt_tokens_details`.
277
+ Cache-read/write counts surface on `TurnUsage.cacheRead` / `TurnUsage.cacheCreation`.
323
278
 
324
279
  ### System-prompt boundary
325
280
 
326
- 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:
281
+ Per-turn churn in the system prompt (`<env>` cwd/date rewrites) used to invalidate the whole cached prefix. `SYSTEM_PROMPT_BOUNDARY` splits it structurally:
327
282
 
328
283
  ```
329
284
  ┌──────────────┐ cache_control: ephemeral
@@ -333,118 +288,69 @@ Per-turn churn in the system prompt (e.g. `<env>` cwd/date rewrites) used to inv
333
288
  └──────────────┘ (no cache_control)
334
289
  ```
335
290
 
336
- 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.
337
-
338
- **Public API** (`zidane`):
291
+ The static half rides the prompt cache across turns/sessions; the dynamic suffix re-bills per turn. Providers strip the marker from wire bytes before the model sees the prompt.
339
292
 
340
293
  | Symbol | Purpose |
341
294
  |---|---|
342
- | `SYSTEM_PROMPT_BOUNDARY` | The literal marker. Embed it in your system prompt to opt in. |
343
- | `splitSystemPrompt(s)` | Returns `{ static, dynamic }`. Marker-free input ⇒ everything is static. |
295
+ | `SYSTEM_PROMPT_BOUNDARY` | The literal marker; embed it in a system prompt to opt in. |
296
+ | `splitSystemPrompt(s)` | `{ static, dynamic }`. Marker-free input ⇒ everything is static. |
344
297
  | `joinSystemPrompt(static, dynamic)` | Compose; marker omitted when `dynamic` is empty. |
345
- | `appendStaticSection(s, extra)` | Append to the static half (used internally for skills + lazy tool catalogs). |
346
- | `appendDynamicSection(s, extra)` | Append to the dynamic half (used by hosts for per-turn state). |
347
- | `replaceDynamicSection(s, next)` | Replace the dynamic half wholesale (used by the TUI's env rewriter). |
298
+ | `appendStaticSection` / `appendDynamicSection` | Append to one half (skills/lazy catalogs use static; hosts use dynamic for per-turn state). |
299
+ | `replaceDynamicSection(s, next)` | Replace the dynamic half wholesale. |
348
300
  | `renderSystemForWire(s)` | Strip the marker for providers that can't honor `cache_control`. |
349
301
  | `hasSystemPromptBoundary(s)` | Boolean probe. |
350
302
 
351
- 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.
352
-
353
- **Provider integration:**
354
-
355
- - `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).
356
- - `openaiCompat` — `toOAIMessages` strips the marker; `applyOAICacheBreakpoints(messages, originalSystem, ttl)` splits the leading system message into static + dynamic content parts when caching is on, stamping `cacheTtl` on each breakpoint (forwarded as-is; OpenRouter's Anthropic-family routes honor it without a beta header — measured).
357
- - `openai` (Codex), `cerebras`, `arcee`, `local`, and any cache-disabled route — marker is collapsed via `renderSystemForWire` so it never reaches the model.
303
+ Built-in profiles (`buildBuildSystem` / `buildPlanSystem` in `zidane/chat`) emit the boundary automatically when `cwd` is provided. `createAgent({ system })` callers stay marker-free by default.
358
304
 
359
305
  ### Cache-stable configuration
360
306
 
361
- A prefix cache breaks at the first differing byte: the provider hashes tools → system → messages cumulatively, so a change at position N invalidates every breakpoint at or after N. Everything in this section follows from one principle — **the wire must be append-only** — and from provider facts that were measured live rather than assumed (the measurements are pinned in `test-inference/cache-stability.inference.test.ts`, so a provider-side change surfaces as a test failure, not a bill).
362
-
363
- Measured facts (Anthropic, Sonnet, ~25k-token seeded transcript):
364
-
365
- | Change between turns | Cache effect |
366
- |---|---|
367
- | Nothing (append-only wire) | ~99.9% hit rate; `cacheRead` grows with the transcript |
368
- | One interpolated counter in the system prompt | Partial break every turn: `cacheRead` pinned to the scaffold constant, conversation re-bills — ~12% hit rate |
369
- | A `read_file` result rewritten in place (`elideStaleReads: 'stub'`) | Partial break at the read's position; bounded to one per read→edit transition (stubbing is idempotent) |
370
- | `thinking.budget_tokens` changed | **Total** loss: `cacheRead: 0`, scaffold included — the budget is part of the cache key |
371
- | >5 min idle | Entry expired; full re-write on the next turn regardless of wire stability |
372
-
373
- The knobs, and the cache-aware setting for each:
307
+ A prefix cache breaks at the first differing byte — the wire must be append-only. Provider facts are pinned live in `test-inference/cache-stability.inference.test.ts`, so a provider-side change surfaces as a test failure, not a bill.
374
308
 
375
309
  | Knob | Cache-aware setting | Why |
376
310
  |---|---|---|
377
- | `elideStaleReads` | `'notice'` | Append-only: a persisted `<system-reminder>` naming the stale path rides the mutating tool's own result. Zero prefix rewrites. `'stub'` (= legacy `true`) reclaims the stale bytes from context instead, at one full-prefix re-bill per read→edit transition — only worth it when context-window pressure outweighs cache cost (break-even ≈ `12.5 × prefix / staleBytes` turns). |
378
- | `thinkingDecay` | Step form `{ afterTurn, floor }` | Every distinct budget a schedule emits is one total cache loss. Step emits one change per run; the geometric form (`factor`) emits one per halving; a per-turn function disables caching entirely for the rest of the run. |
379
- | `cacheTtl` | `'1h'` for interactive hosts | The 5-minute TTL is the dominant cache cost for a human-paced session — one long pause forfeits the whole prefix. `'1h'` (2× write cost, ~0.1× reads) converts idle-loss into a cheap read. Honored by the native `anthropic` provider (ttl + beta header) and forwarded on `openrouter` routes (no beta needed there). Headless back-to-back runs should stay on `'5m'`. Caveat: the wire shape is byte-verified, but our 2026-07 live measurement saw a 1h entry evicted within 6 minutes — run the gated idle test (`INFERENCE_SLOW=1`) against your own credentials before counting the savings. |
380
- | `compactStrategy` / `mediaKeepTurns` | Keep windows equal, chunk-aligned | The two frontier-quantized passes (tail compaction, media age-out) snap to `COMPACT_CHUNK_TURNS` so a window costs one invalidation, not two. Stale-read elision is deliberately NOT quantized — measured worse (it is already idempotent per read). |
381
- | `thinking` level | Constant for the whole run | Toggling thinking on/off mid-run forfeits the prefix even with an unchanged transcript — the thinking config, like the budget, is part of the cache key. Pick a level per run and keep it. |
382
- | `system:transform` handlers | Byte-stable output only | Anything per-turn (counters, timestamps, tallies) belongs in the transcript as immutable history, not in the system prompt — see the hook's docstring for the incident this rule comes from. |
311
+ | `elideStaleReads` | `'notice'` | Append-only: a `<system-reminder>` rides the mutating tool's own result, zero prefix rewrites. `'stub'` reclaims stale bytes at one full-prefix re-bill per read→edit transition. |
312
+ | `thinkingDecay` | Step form `{ afterTurn, floor }` | Every distinct budget is one cache loss; step emits one change per run, geometric emits one per halving, a per-turn function disables caching for the rest of the run. |
313
+ | `cacheTtl` | `'1h'` for interactive hosts, `'5m'` for headless back-to-back runs | The 5-minute default TTL is the dominant cache cost for a human-paced session. |
314
+ | `compactStrategy` / `mediaKeepTurns` | Keep windows equal, chunk-aligned (`COMPACT_CHUNK_TURNS`) | A window then costs one invalidation, not two. |
315
+ | `thinking` level | Constant for the whole run | The thinking config is part of the cache key like the budget; toggling it mid-run forfeits the prefix. |
316
+ | `system:transform` handlers | Byte-stable output only | Per-turn counters/timestamps belong in the transcript, not the system prompt. |
383
317
 
384
- The built-in chat profiles (`BUILD_AGENT` / `PLAN_AGENT`, consumed by the TUI, GUI, and ACP server) ship the cache-aware settings; `test/chat-cache-behavior.test.ts` pins them. What remains outside configuration's reach: TTL expiry beyond `'1h'`, context-window-forced compaction (one intentional break when it happens), provider fallback switching models mid-run, and provider-side eviction. The realistic target is **zero self-inflicted breaks** — one cold write on turn 1, plus whatever the wall clock and the context window force.
318
+ - One interpolated counter in the system prompt drops the hit rate from ~99.9% to ~12% — `cacheRead` pins to the scaffold constant and the conversation re-bills every turn.
319
+ - `thinking.budget_tokens` changes cause **total** cache loss (`cacheRead: 0`); >5 min idle expires the entry regardless of wire stability.
320
+ - TTL eviction inside the window is not guaranteed even at `'1h'` — verify against live credentials (gated idle test, `INFERENCE_SLOW=1`) before counting savings.
321
+ - The target metric is prefix **carry-forward** (`cacheRead(n) === cacheRead(n-1) + cacheCreation(n-1)`), not hit *rate* — a short run with large tool outputs can carry its prefix perfectly while scoring a mediocre ratio.
385
322
 
386
- The end-to-end proof lives in the live suite's *golden configuration* case: a real model driving the real file tools through the loop under the settings above, asserted on **perfect prefix carry-forward** — every turn's `cacheRead` equals the previous turn's `cacheRead + cacheCreation`, i.e. writes only ever cover new transcript. Carry-forward is the metric to reach for over hit *rate*: a short run with large tool outputs can carry its prefix perfectly while scoring a mediocre ratio, because the ratio measures transcript proportions, not cache health.
323
+ Built-in chat profiles (`BUILD_AGENT` / `PLAN_AGENT`) ship these settings; `test/chat-cache-behavior.test.ts` pins them.
387
324
 
388
325
  ### Affinity routing (`promptCacheKey`)
389
326
 
390
- Everything above assumes the second request reaches the machine that cached the first. That is not guaranteed: providers pick a replica by hashing the head of the prompt (OpenAI documents ~the first 256 tokens), so under load a session's traffic can spill onto a cold replica and the prefix is re-billed for reasons the wire cannot explain. `promptCacheKey` is the opt-in hint against that — a stable per-session value the server combines with its own prefix hash to make routing sticky. It is a routing hint, never a cache handle: it cannot make a provider keep or fetch an entry.
391
-
392
- Supported on both provider families, off by default (when unset, requests are byte-identical to before the option existed):
327
+ Providers pick a cache replica by hashing the head of the prompt, so under load a session's traffic can spill onto a cold replica. `promptCacheKey` is a routing hint (never a cache handle) that makes routing sticky — off by default, byte-identical wire when unset.
393
328
 
394
329
  | Provider | Wire shape |
395
330
  |---|---|
396
331
  | `openaiCompat` (and `openrouter` / `baseten`) | `prompt_cache_key` body field; `promptCacheKeyField: 'user'` for legacy routes |
397
332
  | `anthropic` | `metadata.user_id`, merged onto any caller-supplied `metadata` |
398
- | either | `promptCacheKeyHeader: 'x-…'` additionally mirrors it into a header, for gateways that route on one |
333
+ | either | `promptCacheKeyHeader: 'x-…'` additionally mirrors it into a header |
399
334
 
400
- `promptCacheKey: true` derives the key as `sha256(system + first user message)`, 32 hex chars. Deriving beats threading a session id through `StreamOptions`: the derived value is stable for a session's lifetime yet distinct between sessions, needs no plumbing in the loop, and — because the GUI/daemon shares one provider instance across sessions — a construction-time constant would bucket unrelated sessions onto a single replica. It moves exactly when the routable prefix moves (new session, different profile, a compaction that rewrites the head), which are the cases where the old replica's cache is worthless anyway. A hash also keeps prompt text and user identifiers out of a field that leaves the process, which is what Anthropic asks for on `metadata.user_id`. Pass a string or a `(ctx) => string | undefined` resolver instead when the host has a real session id.
401
-
402
- What it actually buys, per route — worth knowing before enabling it:
335
+ `promptCacheKey: true` derives `sha256(system + first user message)` (32 hex chars) — stable per session, distinct across sessions, no plumbing needed. Pass a string or `(ctx) => string | undefined` when the host has a real session id.
403
336
 
404
337
  | Route | Effect |
405
338
  |---|---|
406
- | OpenAI / Azure | Real. Combined with the prefix hash; newer model families require it for the more reliable matching path. |
407
- | OpenRouter | Real. Read as its sticky-routing key, complementing the stickiness it already applies after a cached request. |
408
- | Baseten Model APIs | ~None. Their load balancer is already KV-cache-aware (routes on replica cache state) and caching is unconditional. |
409
- | 1P Anthropic | ~None. Cache entries are scoped per organization/workspace and matched purely by prefix; there is no client-side routing knob. |
410
- | Proxy / AI gateway in front of either (`baseURL`) | Real — this is the case that motivates plumbing it on Anthropic at all. |
411
-
412
- A note on reading the live suite's Baseten case: when Baseten reports no `cached_tokens` at all (cold replica, eviction under load) the cache assertions cannot run, so the case records an explicit `UNVERIFIED` entry and warns rather than passing quietly. `INFERENCE_STRICT=1` promotes that to a failure — use it for a release gate, where "could not verify" must not read as green.
339
+ | OpenAI / Azure, OpenRouter, proxy/gateway in front of either | Real — combines with the provider's own prefix hash. |
340
+ | Baseten Model APIs, 1P Anthropic | ~None — their routing/cache scoping doesn't use a client-side key. |
413
341
 
414
- Two operational notes. OpenAI documents a ceiling of roughly **15 requests/minute per key** before traffic spills anyway; a per-session key sits comfortably under it, a shared constant would not. And a gateway that sets the field itself always wins: a value already present via `extraBodyParams` (or the header via `extraHeaders`) is never overwritten, so "we route" and "the gateway routes" compose instead of fighting. `test/prompt-cache-key.test.ts` pins the derivation, the per-turn stability, and every no-clobber path.
342
+ A gateway that sets the field itself always wins (never overwritten via `extraBodyParams`/`extraHeaders`). `test/prompt-cache-key.test.ts` pins the derivation and every no-clobber path.
415
343
 
416
344
  ### Cache-break diagnosis
417
345
 
418
- `installCacheBreakLogger(hooks, options?)` hashes per-request dimensions (`system`, `tools`, `model`, `thinking`, `thinkingBudget`, `messagePrefix`) at `turn:before` and compares against the previous turn on `turn:after`. When a break is detected 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".
419
-
420
- ```ts
421
- import { installCacheBreakLogger } from 'zidane'
422
-
423
- const uninstall = installCacheBreakLogger(agent.hooks, {
424
- log: line => logger.debug(line), // defaults to console.error
425
- verbose: !!process.env.ZIDANE_DEBUG?.includes('cache'),
426
- })
427
- ```
428
-
429
- Two thresholds decide what counts as a break:
430
-
431
- - `expectedReadAtLeast` (default `1`) — absolute floor. On its own this only catches *total* cache loss.
432
- - `expectedReadRatio` (default `0.8`) — relative floor, compared against the prefix the previous turn established (`cacheRead + cacheCreation`). This is what catches a **partial-prefix break**, where the scaffold still matches so `cacheRead` stays large and non-zero (pinned to a constant) while the whole conversation re-bills at the cache-write rate. Set `0` to restore absolute-only behavior. Skipped until the previous prefix exceeds 1024 tokens, below which the ratio is noise.
433
-
434
- The `messagePrefix` dimension keeps one hash *per message* (`messageHashes`) and diffs them positionally over the range both turns share — so appending messages, which every healthy turn does, is silent, and any in-place rewrite of a shared position (including the previous turn's last message) is reported with its index. It is what catches compaction, stale-read elision, media age-out, and host-side history trimming rewriting the transcript mid-run, none of which touch `system`, `tools`, or the model id.
346
+ `installCacheBreakLogger(agent.hooks, options?)` hashes per-request dimensions (`system`, `tools`, `model`, `thinking`, `thinkingBudget`, `messagePrefix`) at `turn:before` and diffs against the previous turn at `turn:after`, naming the dimension that changed instead of just "cache broke".
435
347
 
436
- Besides the text `log` line, the installer emits the typed **`'cache:break'`** agent hook on every detected break (consumer handler errors are try/caught and routed to `onEmitError`, defaulting into `log` — hosts that count `log` invocations as dimension changes should pass a dedicated sink), carrying a structured `CacheBreakInfo` (`turn`, `model`, `cacheRead`, `cacheCreation`, the floor that tripped, `rebilledTokens` — the approximate re-billed prefix — and the `changed` dimensions + human-readable `details`). Pair `rebilledTokens` with `estimateRebilledCostUsd(provider, model, tokens)` from `providers/cost` to price the break in dollars; it returns `undefined` on a rate miss rather than fabricating $0. Two deliberate asymmetries with the text line: the hook fires even when NO dimension changed — an empty `changed` on a byte-stable request is the TTL-expiry / provider-eviction signature, which grep-oriented logs have always skipped — and it only exists while the installer is attached (like `compact:*`, the loop itself never emits it). The TUI consumes it for its footer chip; hosts can drive metrics or alerts from the same payload.
348
+ - **Thresholds:** `expectedReadAtLeast` (default `1`) catches total loss; `expectedReadRatio` (default `0.8`, relative to the previous prefix, skipped below 1024 prefix tokens) catches a partial-prefix break where `cacheRead` stays pinned to the scaffold while the conversation re-bills.
349
+ - **`messagePrefix`** hashes per-message and diffs positionally: appended messages are silent, an in-place rewrite of a shared position is reported by index — catches compaction, stale-read elision, media age-out, host-side history surgery.
350
+ - Emits `'cache:break'` on every detected break (`CacheBreakInfo`: `turn`, `model`, `cacheRead`, `cacheCreation`, tripped floor, `rebilledTokens`, `changed` dimensions) — pair `rebilledTokens` with `estimateRebilledCostUsd(provider, model, tokens)` from `providers/cost`. Also emits a predictive `'cache:drift'` at `turn:before`, computed from request bytes alone, so it works even on providers that report no cache usage.
351
+ - **Format-casting gateways** (OpenAI Responses/Chat Completions, Bedrock Converse in front of an Anthropic-shaped request) report `cache_creation: 0` always — only the read rate is trustworthy there. `'cache:break'` skips providers that never report `cacheRead` (Codex/Cerebras); `'cache:drift'` still works since it needs no usage data.
437
352
 
438
- Alongside the confirmed-cost hook, the installer emits the predictive **`'cache:drift'`** hook at `turn:before` whenever the outgoing request diverges from the previous completed turn's — computed from the request bytes alone, so it works on providers that report no cache usage and on format-casting gateways where zidane's wire is the last observable point before the cast. The payload (`CacheDriftInfo`) carries the changed dimensions and, for in-place prefix rewrites, the diverged message index. Intentional rewrites drift by design — that is the attribution channel for planned invalidations: tail compaction and media age-out cut on chunk-aligned indices, stale-read stubbing rewrites the read's own position, host-side history surgery lands anywhere. Drift followed by a healthy `cacheRead` means the provider absorbed the change; drift plus a `'cache:break'` on the same turn hands the break its cause. The append-only contract itself is pinned by `test/wire-prefix-monotonicity.test.ts`, which drives every history-touching feature through the real loop and asserts each request is a strict positional prefix of the next.
439
-
440
- Per-turn cache accounting needs no extra plumbing: `turn:after`'s run rollup (`RunUsageMirror`) already carries `totalCacheRead` / `totalCacheCreation` and the full per-turn `turnUsage[]`, and session turns carry `createdAt` for inter-turn wall-clock gaps (the TTL-expiry dimension).
441
-
442
- **Known limitation on format-casting gateways.** When a downstream gateway casts the Anthropic-format request into another provider's shape (OpenAI Responses / Chat Completions, Bedrock Converse), two things degrade by construction:
443
-
444
- - Those vendors have no cache-*write* concept, so their responses map back as `cache_creation: 0`. `previousPrefixTokens` then tracks `cacheRead` alone — the ratio floor underestimates the real prefix, and a carry-forward check (`read(n) === read(n−1) + write(n−1)`) is not evaluable on those routes. The read *rate* remains trustworthy; the write side simply doesn't exist.
445
- - The tracked dimensions cover the request **before** the cast. A transform that isn't prefix-monotonic — `transform(prefix + delta)` must extend `transform(prefix)` byte-for-byte — breaks the vendor's automatic caching while every dimension here reads stable, surfacing as recurring empty-`changed` breaks. That pattern indicts the gateway's cast, not this agent; the gateway should run the same positional hashing (`hashMessages`) on its *output* to name the churn.
446
-
447
- Heuristic only. The confirmed-cost path (`'cache:break'`, the log line) skips providers that don't report `cacheRead` (Codex / Cerebras); the drift path works everywhere because it never needs usage data — on those routes the installer is drift-only, not inert. Pure helpers `snapshotCacheDimensions`, `diffCacheDimensions`, `hashMessages`, and `fnv1a32` are exported for custom telemetry that needs to stamp the same hashes onto spans.
353
+ Per-turn accounting needs no extra plumbing — `turn:after`'s `RunUsageMirror` already carries `totalCacheRead` / `totalCacheCreation` and per-turn `turnUsage[]`.
448
354
 
449
355
  ## Tool aliasing (wire vs. canonical)
450
356
 
@@ -507,60 +413,43 @@ agent.reset() → skills:deactivate (reason: 'reset
507
413
 
508
414
  session-resume rehydration:
509
415
  last-mode-wins per skill across history's skills_use blocks
510
- → trailing mode:'deactivate' STAYS deactivated; activate/deactivate/activate ends active
511
416
  ```
512
417
 
513
- **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.
514
-
515
- **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.
516
-
517
- **Source precedence.** `SkillConfig.source: 'project' | 'user' | 'inline' | 'builtin'`. Project beats user; first-found wins within scan order. `trustProjectSkills: false` drops project-scoped skills.
418
+ - **`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). `AgentToolNotAllowedError.message` points the model at `skills_use({ mode: 'deactivate', name })`.
419
+ - **Body-only delivery.** `skills_use` returns the body (frontmatter stripped) wrapped in `<skill_content>`. Includes shell-interpolated instructions (`` !`cmd` ``, fresh per activation), the skill directory, and the resource listing (not eagerly loaded).
420
+ - **Source precedence.** `SkillConfig.source: 'project' | 'user' | 'inline' | 'builtin'` — project beats user, first-found wins within scan order. `trustProjectSkills: false` drops project-scoped skills.
518
421
 
519
422
  ## Progressive tool disclosure
520
423
 
521
- `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.
424
+ `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` overrides the agent-wide default.
522
425
 
523
426
  ```
524
427
  per run() when toolDisclosure === 'lazy' and ≥1 MCP tool is lazy:
525
428
 
526
- partitionToolDisclosure(toolsBySpecName, mcpToolNames, servers, mode, aliases)
527
- → lazyEntries (wire name + canonicalName + description + inputSchema + server?)
528
- → eagerCanonicalNames
429
+ partitionToolDisclosure(...) → lazyEntries (wire name + canonicalName + description + inputSchema)
430
+ → eagerCanonicalNames
529
431
 
530
432
  initialUnlocked = new Set(eagerCanonicalNames) // never mutated — phase-1 filter
531
433
  unlocked = new Set(eagerCanonicalNames) // mutated as tools surface
532
434
  dynamicUnlockOrder = [] // append-only log
533
435
 
534
- if (lazyEntries && toolSearch.tool !== false && !host-defined tool_search):
535
- tools.tool_search = createToolSearchTool({ catalog: lazyEntries, unlocked, addUnlock, defaultLimit })
536
- unlocked.add('tool_search')
537
- initialUnlocked.add('tool_search') // pinned to the cached prefix
538
-
539
- system += buildSearchableCatalog(lazyEntries) // wire names, XML-escaped, no inputSchema
540
- installLazyDisclosureGate // tool:gate refuses lazy ∉ unlocked
436
+ tools.tool_search = createToolSearchTool({ catalog: lazyEntries, unlocked, addUnlock })
437
+ system += buildSearchableCatalog(lazyEntries) // wire names, XML-escaped, no inputSchema
438
+ installLazyDisclosureGate // tool:gate refuses lazy ∉ unlocked
541
439
 
542
440
  per turn — buildFormattedTools() emits in two phases:
543
441
  1. registry-order walk filtered by initialUnlocked ← byte-stable cached prefix
544
442
  2. dynamicUnlockOrder, in unlock order ← append-only tail
545
443
 
546
- tool_search({ names | query | server, limit? }):
547
- for each match: unlocked.add(canonical); addUnlock(canonical)
548
- (addUnlock is the framework-supplied recorder that
549
- appends to dynamicUnlockOrder; legacy hosts that only
550
- pass `unlocked` still work, just lose tail-ordering.)
551
- return <tool_search_results>…<input_schema>{json}</input_schema>…
552
- (inputSchema JSON inlined with `<` → `\u003c` so a hostile schema can't break the envelope)
444
+ tool_search({ names | query | server, limit? }): unlocks matches, returns inlined input_schema JSON
553
445
 
554
446
  run end: uninstallLazyDisclosureGate; per-run state GC-eligible on next run()
555
447
  ```
556
448
 
557
- **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.
558
-
559
- **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.
560
-
561
- **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.
562
-
563
- **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`.
449
+ - **Aliasing.** `LazyToolEntry.name` is the wire name; `.canonicalName` keys `unlocked` and dispatches `ctx.tools[name]`. `session.turns` / `ToolHookContext.name` stay canonical throughout.
450
+ - **Hard gate vs. provider validation.** Anthropic/OpenAI already refuse `tool_use` blocks for tools absent from the request; the gate exists for lenient/custom/mock providers and makes the lazy boundary observable via `tool:gate`.
451
+ - **Cache-stable injection.** Every `tool_search` call invalidates only the wire tools array's SUFFIX starting at the first new schema — the eager + `tool_search` prefix never moves.
452
+ - **Host-defined `tool_search`.** A host tool named `tool_search` shadows auto-injection; it must call `createToolSearchTool({ catalog, unlocked, addUnlock? })` to drive unlocks, or use `toolSearch.tool: false` for a fully custom flow.
564
453
 
565
454
  ## Hook Firing Order
566
455
 
@@ -577,11 +466,9 @@ Every run ends with an implicit deactivate-all pass (`reason: 'run-end'`); activ
577
466
 
578
467
  ### Per turn
579
468
 
580
- All tool hooks carry `turnId` + `callId`. Typed via `ToolHookContext` / `McpToolHookContext`. Both expose `name` (canonical) and `displayName` (wire / aliased; defaults to `name`).
581
-
582
- Hooks marked **[mutable]** have ctx fields that affect downstream behavior when handlers mutate them. Last-writer wins.
469
+ All tool hooks carry `turnId` + `callId`, typed via `ToolHookContext` / `McpToolHookContext`, both exposing `name` (canonical) and `displayName` (wire; defaults to `name`). Hooks marked **[mutable]** have ctx fields handlers can mutate; last-writer wins.
583
470
 
584
- > **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.
471
+ > **Ordering note:** `context:transform` fires **before** `turn:before` — it lands upstream of the defensive pairing repair, 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; the loop re-runs both repair passes after it. Consumers building provider requests outside `agent.run()` should use `session.toMessages({ provider })` / `toWireMessages(turns, { provider })`, which apply the same repair.
585
472
 
586
473
  ```
587
474
  context:transform [mutable: messages] ← edit messages before LLM call
@@ -590,21 +477,22 @@ turn:before ← turn starts
590
477
  oauth:refresh ← (when provider supports it)
591
478
  stream:thinking (n) / stream:text (n) ← each streamed chunk
592
479
  stream:end ← text complete (if text present)
593
- stream:retry (n) ← retryable error caught; sleep then re-enter stream (only fires on intermediate failures)
594
- model:fallback ← terminal failure advanced the run's provider+model fallback chain (run-sticky; only the LAST target's failure reaches stream:error)
595
- stream:error ← provider rejected terminally (omitted on user-initiated aborts and on retries that recover)
480
+ stream:retry (n) ← retryable error caught; sleep then re-enter stream
481
+ model:fallback ← terminal failure advanced the run's fallback chain (only the LAST target's failure reaches stream:error)
482
+ stream:error ← provider rejected terminally (omitted on user aborts and recovered retries)
596
483
  turn:after ← always fires (incl. errors); SessionTurn + toolCounts
597
484
  tool:gate [mutable: block, reason, result?] ← refuse / substitute / run
598
485
  tool:unknown [mutable: result?, suppressError] ← no toolDef registered
599
486
  validation:reject / validation:coerce ← arg validation outcomes
600
- tool:before ← (input coerced; coercions? when present; priorContent? for write_file)
487
+ tool:before ← (input coerced; coercions? when present)
601
488
  mcp:tool:gate [mutable: block, reason, result?]
602
489
  mcp:tool:before
603
490
  mcp:tool:transform [mutable: result] ← + outputBytes pre-mutation
604
491
  mcp:tool:after ← + outputBytes post-mutation
605
492
  tool:transform [mutable: result, isError] ← + outputBytes pre-mutation
493
+ artifact:recorded ← per artifact lifted from ctx.metadata.artifacts (docs/ARTIFACTS.md)
606
494
  tool:after ← + outputBytes post-mutation
607
- tool:error [mutable: result?] ← on execute throw; ctx.result substitutes
495
+ tool:error ← on execute throw; ctx.result substitutes
608
496
  tool:cancelled ← agent.cancelTool(callId); skips transform/after; wire = TOOL_USE_CANCELLED_MESSAGE
609
497
  tool-results:after ← after the tool-results user turn is pushed (persistence seam)
610
498
  usage ← running totals
@@ -613,70 +501,51 @@ budget:exceeded / tool-budget:exceeded ← byte / per-tool caps trippe
613
501
  repeat-guard:exceeded ← repeat-guard block/steer/abort (behavior.repeatGuard)
614
502
  ```
615
503
 
616
- **`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.
617
-
618
- **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.
504
+ - **`tool-results:after`** is the durable-persistence seam: the assistant turn's `tool_use` blocks and the matching `tool_result` user turn must persist as a pair, or a process death between them leaves an orphan `tool_use` that Anthropic rejects on resume.
505
+ - **Unknown-tool path.** `tool:before` fires first with `ctx.unknown: true`, then `tool:unknown`, then `tool:error` (unless `suppressError`), then `tool:dispatched` with `outcome: 'unknown'`. `tool:after` never fires — the body never executed.
619
506
 
620
507
  ### Per run
621
508
 
622
509
  ```
623
510
  session:start ← run begins
624
- session:turns ← seeded user turn persisted (fires before the first turn:before
625
- when run({ prompt }) seeds a user turn; skipped on promptless resume)
626
- system:before ← per-run only when run({ system }) is set. Observational —
627
- the loop reads options.system directly; ctx mutations are dropped.
628
- Use system:transform for actual mutation.
511
+ session:turns ← seeded user turn persisted (before the first turn:before;
512
+ skipped on promptless resume)
513
+ system:before ← per-run only when run({ system }) is set; observational
629
514
  [turn hooks above, repeated]
630
- session:turns ← after turns persisted (includes SessionTurn[] of appended turns)
515
+ session:turns ← after turns persisted (SessionTurn[] of appended turns)
631
516
  steer:inject ← if steering/follow-up message injected
632
- agent:abort ← agent.abort() called mid-run
633
- agent:done ← run finished (all exit paths), includes stats.output
634
- session:end ← run finished (completed | aborted | error)
517
+ agent:abort ← fires once if the run was aborted (loop's between-turn
518
+ check, or agent.run()'s mid-stream-throw catch — mutually exclusive)
519
+ session:end ← run finished (completed | aborted | error);
635
520
  includes turnRange [start, end] for cleanup
521
+ agent:done ← always fires last (all exit paths); includes stats.output
636
522
  ```
637
523
 
638
524
  ### Background task lifecycle
639
525
 
640
- 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>/sessions/<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).
641
-
642
- **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.
526
+ Fires when the model invokes `shell({ run_in_background: true })`. Full mechanics (spawn, log files, notification queue, destroy ordering, orphan reaping) live in `docs/RUN_IN_BACKGROUND.md`.
643
527
 
644
528
  ```
645
- background:start ← spawn succeeded; payload carries taskId + pid + outputPath
646
- (observational; tools don't wait on listeners)
647
- background:exit ← at-most-once per task; the context's settle latch
648
- guarantees no duplicate fires (close-or-error race-safe)
529
+ background:start ← spawn succeeded; payload carries taskId + pid + outputPath (observational)
530
+ background:exit ← at-most-once per task
649
531
  execution:ready ← once per agent when the execution handle binds
650
- (spawned or host-injected via AgentOptions.handle)
651
532
  ```
652
533
 
653
- **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.
654
-
655
- **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.
656
-
657
- **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.
658
-
659
- **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.
660
-
661
534
  ### Where MCP servers run (`scope: 'host' | 'context'`)
662
535
 
663
- An MCP server is an ordinary program: its `fs.readFileSync` / `fs.writeFile` hit whatever filesystem it was spawned on. By default (`scope: 'host'`) stdio servers are spawned as children of the agent process via `StdioClientTransport`, so under a sandboxed execution context the agent's own tools and its MCP servers see **different filesystems** — a path the model found with `list_files` doesn't exist for the server, and an MCP write lands where `read_file` can't see it.
664
-
665
- `McpServerConfig.scope: 'context'` relocates a stdio server into the execution context through the optional `ExecutionContext.execStream` seam (`capabilities.stream`). `createTransport` then builds `createContextStdioTransport` (`src/mcp/context-transport.ts`) instead, reusing the SDK's own `ReadBuffer` / `serializeMessage` for newline framing — `ReadBuffer` decodes only at newline boundaries, which is why the seam carries bytes: decoding per chunk would corrupt any multibyte codepoint split across a boundary.
536
+ By default (`scope: 'host'`) stdio MCP servers spawn as children of the agent process, so under a sandboxed execution context the agent's tools and its MCP servers see **different filesystems**. `McpServerConfig.scope: 'context'` relocates a stdio server into the execution context via `ExecutionContext.execStream` (see `docs/EXECUTION_CONTEXT.md` for the context types).
666
537
 
667
- Opt-in rather than automatic, because it's wrong for most servers: a GitHub / Linear / Sentry MCP wants the host's credentials and only talks to the network. Only workspace-touching servers (filesystem, git, build tooling) want `'context'`. Two consequences: the context's image must contain the server's runtime (`node`, `uvx`, …), and this buys filesystem **coherence, not isolation** — remote-transport MCP servers can never be relocated, so the agent's reach is still not bounded by the sandbox. A `scope: 'context'` server on a context without `execStream` is a bootstrap error, never a silent fallback to a host spawn.
538
+ - Opt-in per server — remote-transport and credentialed servers (GitHub/Linear/Sentry) stay host-side; only workspace-touching servers (filesystem, git, build tooling) want `'context'`. A `scope: 'context'` server on a context without `execStream` is a bootstrap error, never a silent host-spawn fallback. Buys filesystem **coherence, not isolation**.
539
+ - Lazy binding (`getHandle()`) — `warmup()` still fans MCP connect and skills out in parallel with no ordering special case. Subagents reuse the parent's live connection instead of cold-booting a sandbox per spawn. Reconnect after a mid-session server death rebuilds through `execStream`, never a host-spawn fallback; the transport is branded `__contextScoped` so pid-based kill utilities refuse to touch it.
668
540
 
669
- **Ordering.** The agent always hands the connector an `McpExecutionBinding`, but the binding is **lazy**: it carries `getHandle()` rather than a resolved handle, and `createTransport` calls it only when a `scope: 'context'` stdio server actually builds its transport. So `warmup()` keeps fanning MCP connect and skills out in parallel, host-only setups never spawn, and there is no ordering special case — the handle necessarily exists by the time `execStream` runs because the same call site awaits it. This also matters for sub-agents: `spawn` hands the child the parent's inherited `mcpServers` alongside a `nonOwningMcpConnector` that reuses the parent's live connection, so the child builds no transport and must not cold-boot a sandbox per spawn.
670
-
671
- **Lifecycle.** Reconnect after a mid-session server death goes through `makeLazyConnector`, which captures the binding in its closure and rebuilds the transport through `execStream` — a reconnect can never silently fall back to a host spawn. `connection.close()` reaches `StreamHandle.kill()`, so no server survives in the sandbox to accumulate across reconnects. The context transport is branded `__contextScoped`, and `stdioPidOf` refuses to read a pid from it: `killStdioChild` signals **host** pids, and a sandbox pid (often a small number like `77`) names an unrelated host process.
672
-
673
- **Per-context backing.** `process` uses a piped `spawn`. `docker` uses the `docker exec -i` **CLI**, not dockerode: dockerode's hijacked exec (the only HTTP-API route to a writable stdin) rides on docker-modem's HTTP 101 upgrade handling, which hangs under Bun — verified working under Node, so it's a runtime incompatibility rather than misuse. `e2b` uses `commands.run({ background: true, stdin: true, timeoutMs: 2^31-1 })` + `sendStdin` — all three options non-default and each load-bearing (no `stdin` → immediate EOF; the 60s default `timeoutMs` → the server is killed mid-session; and NOT `timeoutMs: 0`, which `@connectrpc/connect`'s deadline signal treats as already expired). `daytona` must use a **PTY**, since its process/session API has no stdin at all; that forces `stty raw -echo` (terminal echo would return our own writes as responses; canonical mode rewrites `\n`→`\r\n` and caps lines near 4096 bytes) and a stderr redirect to a file (one terminal stream means stderr would otherwise interleave into the protocol). The real command lives in an uploaded script — the typed launch line is itself subject to canonical-mode echo and the line cap, so it stays short and constant — and the stderr file is polled back through the exec API onto `StreamHandle.stderr` (the poll carries a `ZEOF-<token>:<wc -c>` suffix so the next offset comes from the file's real size rather than from possibly-transformed content), then removed on teardown.
674
-
675
- **Two non-obvious behaviours, both found only by running against live infrastructure.**
676
-
677
- *Daytona's PTY is `lazyStart`: the shell does not exist when you type into it.* The launch line is buffered, then the login shell boots, prints a prompt, and replays the input through its line editor — so the raw stream opens with the echoed launch line, the echoed first request, bracketed-paste escapes and a `%` prompt. An MCP client parses that echoed request as a message and the handshake collapses. `execStream` therefore gates the stream on a readiness sentinel the *child* prints (`printf 'Z-%s-READY\n' <token>`) and discards every byte before it. The sentinel is assembled at runtime so the echoed source line — which contains the format string, not the result — cannot false-match it.
541
+ | Backend | Transport | Load-bearing quirk |
542
+ |---|---|---|
543
+ | `process` | Piped `spawn` | — |
544
+ | `docker` | `docker exec -i` CLI, not dockerode | Dockerode's hijacked-exec stdin hangs under Bun (works under Node). |
545
+ | `e2b` | `commands.run({ background, stdin: true, timeoutMs })` + `sendStdin` | Default 60s `timeoutMs` kills the server mid-session; `0` is treated as already-expired. |
546
+ | `daytona` | PTY (`stty raw -echo`) + stderr redirected to a polled file | No stdin API; shell is `lazyStart` and echoes the launch line, so the stream gates on a ready sentinel the child prints. |
678
547
 
679
- *Killing a context-scoped server needs a process group, and neither sandbox gives you one for free.* `npx`-style launchers fork the real server as a grandchild that survives a pid-only kill and accumulates across reconnects. Both `docker` and `e2b` therefore launch under `setsid -w` (with a runtime probe falling back to a plain exec on images whose setsid lacks `-w`; bare `setsid` is not an option, as its parent exits immediately and tears the stream down) and record the session leader's pid, which is then the PGID. On E2B this is a safety requirement, not an optimisation: the pid E2B reports runs in **envd's own process group**, so a group kill on it would take down the sandbox agent. The group signal is `kill -TERM -$pid` with no `--` — dash's kill builtin rejects it.
548
+ `docker` and `e2b` launch under `setsid -w` and record the session leader's pid as the kill target — a pid-only kill leaves `npx`-style grandchildren running, and on E2B the reported pid is envd's own process group, so killing it directly would take the sandbox agent down too.
680
549
 
681
550
  ### Once (lazy on first `warmup()` / `run()` / `activateSkill()` — or eager when `eager: true`)
682
551
 
@@ -684,17 +553,17 @@ Opt-in rather than automatic, because it's wrong for most servers: a GitHub / Li
684
553
  mcp:bootstrap:start / mcp:bootstrap:end ← per server, always fires (durationMs + ok/toolCount | error)
685
554
  mcp:connect ← per server bootstrapped OK
686
555
  mcp:error ← per server that failed (fatal: connect/transport)
687
- mcp:warn ← per server diagnostic line (non-fatal; currently: stdio child stderr, one event per line)
556
+ mcp:warn ← per server diagnostic line (non-fatal; e.g. stdio child stderr)
688
557
  skills:resolve ← after discovery
689
558
  skills:catalog [mutable: catalog] ← after catalog build
690
559
  skills:activate ← during run, when a skill file is read
691
560
  ```
692
561
 
693
- `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.
562
+ `warmup()` fans MCP connect + skills resolution out in parallel; both sides share an in-flight promise. Hooks registered after `warmup()` resolves may miss `skills:resolve`/`skills:catalog` — register before any `warmup()`/`run()`/`activateSkill()` call (or before `createAgent` returns with `eager: true`).
694
563
 
695
564
  ### Spawn lifecycle
696
565
 
697
- 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.
566
+ Fires when a parent invokes the `spawn` tool. Children inherit the parent's preset (tools, system, aliases, mcpServers, skills, behavior) by default.
698
567
 
699
568
  ```
700
569
  spawn:before ← child agent created, before run
@@ -702,7 +571,7 @@ spawn:complete ← child finished successfully, includes AgentStats
702
571
  spawn:error ← child run threw
703
572
  ```
704
573
 
705
- The child's lifecycle also bubbles to the parent hook surface with `childId` + `depth`, so nested UI renders without subscribing on the child:
574
+ The child's lifecycle also bubbles to the parent hook surface with `childId` + `depth`:
706
575
 
707
576
  ```
708
577
  child:stream:text / child:stream:thinking / child:stream:end / child:stream:error
@@ -713,19 +582,10 @@ child:tool:before / child:tool:after / child:tool:error
713
582
  child:turn:after
714
583
  ```
715
584
 
716
- `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.
717
-
718
- **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:
585
+ `BUBBLED_MUTABLE_EVENTS` (`src/tools/spawn.ts`) lists `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.
719
586
 
720
- - `system` — preset system prompt. Per-call `input.system` still wins (model specialization beats preset defaults).
721
- - `model` — default model for this subagent type (e.g. an Explore preset pinned to a cheap model). Precedence: per-call `input.model` > this > `SpawnToolOptions.model` > inherit the parent run's model.
722
- - `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.
723
- - `readonly: true` — convenience for `tools: ['read_file', 'grep', 'glob', 'list_files']`. `tools` wins when both are set.
724
- - `description` — short blurb rendered into the schema field so the model can pick a type without round-tripping through docs.
725
-
726
- 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).
727
-
728
- **Subagent model catalog** (`createSpawnTool({ models })` / `spawnTool.withModels(catalog)` for hosts whose provider list is only known at agent-build time). When set, the schema gains an optional `model` field so the parent can delegate to cheaper models per call. ≤ 8 entries embed id + pricing + context window in the field description; larger catalogs defer to a host-registered `model_search` tool (`createModelSearchTool(catalog)`). Each entry carries the `Provider.name` that serves it, and the child is routed to that provider (resolved via `ToolContext.providers`, the run's fallback-chain provider list). Unknown ids reject with closest matches so the model can self-correct; no catalog keeps the historical schema and children always inherit the parent's model.
587
+ - **Subagent type registry** (`createSpawnTool({ subagents })`) adds a `subagent_type` enum to the schema; the matching `SubagentDef` overlays `system`, `model`, `tools` (allow-list), `readonly` (shorthand for `['read_file','grep','glob','list_files']`), and `description` for that one dispatch. Unknown types route to `'general-purpose'`.
588
+ - **Subagent model catalog** (`createSpawnTool({ models })`) adds an optional `model` field so the parent can delegate to cheaper models. ≤ 8 entries embed pricing in the schema; larger catalogs need a host-registered `model_search` tool.
729
589
 
730
590
  ## Dependency Graph
731
591
 
@@ -769,98 +629,61 @@ flowchart TB
769
629
 
770
630
  ## Presets
771
631
 
772
- A `Preset` is `Omit<Partial<AgentOptions>, 'provider' | 'execution' | 'session' | 'mcpConnector'>` — a reusable slice of agent config that consumers spread into `createAgent`:
632
+ A `Preset` is `Omit<Partial<AgentOptions>, 'provider' | 'execution' | 'session' | 'mcpConnector'>` — a reusable slice of agent config consumers spread into `createAgent`:
773
633
 
774
634
  ```ts
775
635
  createAgent({ ...basic, provider })
776
636
  ```
777
637
 
778
- Excluded fields are all ambient / per-invocation runtime wiring (`provider`, `session`, `execution`, and `mcpConnector` — the MCP connection seam, often closure-bound to a transport / auth provider). Everything else — `name`, `system`, `tools`, `toolAliases`, `behavior`, `mcpServers`, `skills`, `eager`, **`hooks`** — is preset-shareable.
779
-
780
- ### `hooks` field
638
+ Excluded fields are ambient/per-invocation runtime wiring. Everything else — `name`, `system`, `tools`, `toolAliases`, `behavior`, `mcpServers`, `skills`, `eager`, **`hooks`** — is preset-shareable.
781
639
 
782
- `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.
640
+ `AgentOptions.hooks` registers for the agent's lifetime (same effect as `agent.hooks.hook(event, fn)` right after `createAgent`); unknown event names throw at construction. `AgentRunOptions.hooks` is the per-run, auto-detaching equivalent.
783
641
 
784
- Each entry accepts a single handler or an array. The array form is what `composePresets()` emits when multiple presets target the same event.
785
-
786
- ### `composePresets(...presets)` — field-aware merge
787
-
788
- 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:
642
+ Bare `...spread` is shallow — two presets both registering `turn:before` would silently lose one handler. `composePresets(...)` is the explicit merge primitive:
789
643
 
790
644
  ```ts
791
645
  createAgent({ ...composePresets(basic, telemetry, mine), provider })
792
646
  ```
793
647
 
794
- Merge rules:
795
-
796
- | field | rule |
797
- | ---------------------------------------- | ----------------------------------------------------- |
798
- | `name`, `system`, `eager`, `skills` | last-defined wins |
799
- | `tools`, `toolAliases`, `behavior` | shallow-merge (later keys override per field) |
800
- | `mcpServers` | concat with last-wins on `name` collision (a later preset can override an earlier preset's `github` server) |
801
- | `hooks` | per-event concat — every handler fires, in preset order |
802
-
803
- `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.
804
-
805
- ### When to reach for what
648
+ | Field | Rule |
649
+ |---|---|
650
+ | `name`, `system`, `eager`, `skills` | Last-defined wins. |
651
+ | `tools`, `toolAliases`, `behavior` | Shallow-merge (later keys override per field). |
652
+ | `mcpServers` | Concat, last-wins on `name` collision. |
653
+ | `hooks` | Per-event concat — every handler fires, in preset order. |
806
654
 
807
- - **One preset, no overlap with consumer code** → spread directly: `createAgent({ ...preset, provider })`.
808
- - **Multiple presets** → `composePresets(...)` first. Spread alone gives you last-wins on `hooks` and `behavior` field-overrides you probably didn't intend.
809
- - **Dynamic registration after creation** → `agent.hooks.hook(event, fn)` (returns an `unregister` fn). Preset hooks are "baked in"; runtime hooks are dynamic.
810
- - **Per-run, auto-detached** → `agent.run({ hooks: { … } })`. Same `event → fn | fn[]` shape.
655
+ Use `agent.hooks.hook(event, fn)` for dynamic post-creation registration (returns an `unregister` fn), and `agent.run({ hooks })` for per-run auto-detached handlers.
811
656
 
812
657
  ## Option Precedence
813
658
 
814
- `run.behavior` > `agent.behavior` > defaults (field-by-field merge).
815
-
816
- Defaults: unlimited turns (set `maxTurns` as a safety net), model-driven output (`maxTokens` unset → Anthropic sends the model's max output since the field is mandatory, OpenAI-style providers omit it so the model runs to a natural stop; set a number for a hard per-turn cap), 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: 0` (unlimited — empty-pause exhaustion no longer ends a live run by default), `persistTurns: true`, `providerStreamStartTimeoutMs: 300000`, `providerStreamTimeoutMs: 1800000`, and `mcpConnectTimeoutMs: 120000` for custom connectors. `persistTurns: true` means 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`, `thinkingOverrunPolicy`, `readFileDefaults`, `writeFileExistingPolicy`, `textToolCallRescue`, `unknownToolRecovery`, `progressNudges`, `maxTurnsWarningMessage`, `toolSearch`, `mediaKeepTurns`, `autoCompact`, `persistThreshold`, `persistDir`, `persistExcludeTools`, `persistMaxBytes`, `maxCostUsd`, `maxTotalTokens`, `resumeOnMaxTokens`, `retry`, `toolBatchTimeoutMs`) 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**, enabling `repeatGuard` in `mode: 'both'`, and setting `mediaKeepTurns: 8`) — see CHAT.md → Agents.
817
-
818
- **`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:
819
-
820
- > `[Tool output budget exceeded: N bytes returned in this turn (cap: M). Summarize the salient findings before calling more tools.]`
821
-
822
- `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.
823
-
824
- **`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).
825
-
826
- **`mediaKeepTurns`** - wire-only media age-out for long image/document sessions. When set, inline top-level `image` / `document` blocks and media nested in `tool_result` outputs older than the last `mediaKeepTurns` messages are replaced on the provider wire with a short marker carrying an `attachment://<hash>` handle. The persisted `session.turns` still keep the original base64 bytes; `read_file path="attachment://<hash>"` resolves the handle from session history and re-inlines the media for that turn. This is separate from `compactStrategy: 'tail'`: tail compaction only stubs old `tool_result` text and deliberately does not trim media bytes. The frontier is chunk-snapped for prompt-cache stability, so aged media drops out in batches rather than one message at a time. SDK default is off; chat profiles set `mediaKeepTurns: 8` to align with their `compactKeepTurns`. Proactive age-out requires the `read_file` tool to be registered, so the marker is actionable. Reactive `context_exceeded` recovery may still strip media even without `read_file`; in that case the marker says the handle is saved but cannot be re-opened in the current run.
827
-
828
- **`autoCompact`** — loop-native auto-compaction (`boolean | AutoCompactConfig`). At each turn boundary the loop computes the last turn's effective input (`input + cacheRead + cacheCreation`, so prompt caching can't hide a full window) and, once it crosses `threshold` (default `0.85`) of the effective context window, runs a real summary over the older turns (`compactConversation`, `scope: 'tail'`), appends a `compact-summary` marker plus optional file/skill restoration (`buildPostCompactAttachments`) to the session, and continues — the next turn's `applyCompactSummaryCutoff` elides the summarized turns. Fires mid tool-loop AND at end-of-run (so a resumed session starts compacted). Window source is `autoCompact.contextWindow` → `provider.meta.contextWindow`; with neither it's inert (the SDK core has no model catalog, so chat hosts inject the picked model's window). A hysteresis floor (`minGrowthFraction`, default `0.1`) stops post-compact thrashing and a consecutive-failure circuit breaker (`maxFailures`, default `2`) disables it for the run if the summary keeps failing. Distinct from `compactStrategy` (per-turn wire-only tool-output trim) and the always-on reactive `context_exceeded` recovery; observe via the `compact:start` / `compact:end` hooks. SDK default off; chat profiles enable it (`{ threshold: 0.85 }`) and the TUI/GUI inject the window + honor the user's enable/threshold settings per run.
829
-
830
- **`modelCatalog`** — override model metadata (output ceiling, per-MTok rates, context window, `reasoning`) for models pi-ai's bundled registry gets wrong or has never heard of: gateway aliases, private deployments, post-pin releases, non-Anthropic models served over the Anthropic wire. Accepts a table keyed `"<provider>:<modelId>"` or a `{ get, list }` source (`src/providers/model-catalog.ts`). Resolution is first-hit-wins: **run `behavior.modelCatalog` → agent `behavior.modelCatalog` → process-wide `setModelCatalog()` → pi-ai registry → local fallback tables** (`EXTRA_COST_RATES`, `defaultAnthropicMaxTokens`). Entries merge **field-level** over the bundled entry (`cost` merges per-rate), so `{ maxTokens: 128000 }` corrects one field and inherits the rest. Everything stays keyed by `(provider, modelId)` — overriding one provider leaves the others fully bundled. `get` returning `undefined` falls through; `list` returning an array claims **ownership** of that provider's picker (so it can hide as well as add, and suppresses our `extraModels` stopgap), `[]` empties it, `undefined` keeps the bundled list. The table form only ever overlays, and an id-less `list` entry is dropped with a one-shot warning (it would render a blank, selectable picker row). An entry with no `contextWindow` leaves the window **unknown** rather than zero — `getContextWindow` treats non-positive values as absent and falls through to `contextWindowEnvVar` / `null`. The provider key is the pi-ai registry id (usually the factory name); the Codex provider is the exception, storing records under `openai-codex` and pricing under `openai`, and accepts an entry keyed either way on BOTH sides — the wire path via `aliasCatalogProviders`, the host path via the descriptor's key set (`catalogProvidersOf`) — so an override can't half-apply (right invoice, missing context gauge). `estimateRebilledCostUsd` takes the catalog as an argument instead: cache-break telemetry is computed by the host that owns the `cache:break` hook, outside any run context, so a host using a catalog must pass the same one there. The `{ get, list }` form holds functions and therefore cannot be snapshotted by a durable/replay runtime that serializes `behavior` — use the table form or install it with `setModelCatalog` at boot; and prefer `behavior.modelCatalog` over the process-wide setter in multi-tenant servers. Threaded as `StreamOptions.modelCatalog`, so per-agent catalogs reach the wire path (Anthropic's `max_tokens` default + clamp, `fillEstimatedCost` for all ten providers, Codex model resolution) without process-wide state — two agents can carry different catalogs. The chat/TUI layer (`modelsForDescriptor`, `getModelInfo`, context gauge) resolves metadata before any agent exists, so it reads the process-wide catalog only. SDK default: unset — the bundled registry is authoritative.
831
-
832
- **`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 (the message notes that shell reads — cat/grep/sed — don't register). On-disk hash drift is **anchor-tolerant**: the hazard drift creates is a mis-anchored `old_string`, so if the edit still resolves VERBATIM against the current bytes it proceeds with a disclosure note; when resolution fails or only a fuzzy recovery (quote normalization, line-number strip) matches — drift + fuzzy being the one combination where mis-anchoring is genuinely plausible — the drift is refused with "re-read the file" — and consecutive refusals without an intervening read escalate (`staleRejections` on the entry, reset by any fresh read or successful edit) to name the likely external writer (editor auto-save, formatter, codegen watcher). Flat drift refusals are what taught models to abandon `edit` for shell one-liners that bypass every guard. Both tools update the hash on success so chained edits don't need re-reads — and set the entry's `elided` flag in the same update, so the next re-read misses dedup and serves the fresh body instead of a "still current" stub pointing at the pre-mutation output (`write_file` seeds with the flag for the same reason). Dedup resumes after that one honest read; the flag never affects the edit gate. 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. The map is **never serialized**, so a host that reloads a fresh `Session` from a store on resume — or a durable/replay runtime where a journaled `read_file` never re-runs its live `rememberRead` — would start with an empty map and the gate would reject every edit whose read happened in an earlier turn. To prevent that, a `tool:gate` hook (installed when `requireReadBeforeEdit` is on) pre-hydrates the map from `session.turns` right before an `edit`/`multi_edit` runs, when its target path is missing from the map: transcript reconstruction for full reads, an `execution.readFile` re-hash fallback for partial ones, alias-aware for wire-named transcripts. It fires **inside the loop, outside any journaled tool body, on every replay**, so the verdict can never be frozen by a host that journals tool execution; it has an O(1) `Map.has` fast path so it only scans when a path is genuinely absent, and never blocks (the authoritative read/drift verdict stays in the tool body). The `restateTool` wrapper additionally hydrates once per process for `read_file` dedup; it is a complement, not what the gate depends on.
833
-
834
- **`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:
835
-
836
- - **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.
837
- - **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."`.
838
- - **`scope: 'session' | 'run'`** (any mode) — keying granularity for the dedup counter. `'session'` (default) keys `(session, toolName)` — right for pure tools (`read_file`, `grep`). `'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`.
839
-
840
- Tools with side effects or non-deterministic outputs MUST NOT be listed (no safety net beyond the consumer hasher).
841
-
842
- **`ToolContext.state`** (`src/tool-state.ts`) is durable key/value state for tools — the supported way to keep a one-shot marker, a cursor, or a dedup flag that must outlive the process. It exists because a closure variable can't: under a durable runtime a journaled tool result skips the body on replay, so a mutation made inside `execute` silently doesn't happen the second time. Reads are synchronous against a snapshot the agent hydrates once per agent instance (before the first dispatch, memoized on the controller); writes are staged as ops and committed by the dispatch path when the call settles — in call-index order, never completion order, and a rejecting store commit drops the write rather than failing the call. `AgentOptions.toolState` supplies the durable backing (`ToolStateStore` = `load` + `commit`, runtime-agnostic; `zidane/restate` ships `restateToolStateStore`); with none configured the map is in-memory and agent-scoped, so tools behave identically locally minus durability. The three-phase split is what lets a durable adapter journal the staged delta *as data* alongside the tool result and re-issue the identical write on replay — see [Durable tool state](./RESTATE.md#durable-tool-state). A rejecting store never fails the call (the body already ran): it fires `tool-state:commit-failed` with the lost ops and otherwise logs. Values are size-checked at `set()` time against `ToolStateStore.maxValueBytes` (default 64 KiB of JSON) so an oversized write throws in the tool that caused it rather than at commit — under Restate that difference is a failed-and-retried invocation versus a clean error.
843
-
844
- **`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.
845
-
846
- **`thinkingDecay`** tapers `thinkingBudget` per run-relative turn (1-indexed). `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. Skipped for `thinking: 'adaptive'` only on providers with `adaptiveBudgetIsEnvelope` (Anthropic), where `thinkingBudget` caps the whole response envelope (`max_tokens`) and decaying it would strangle text/tool output; providers where adaptive `thinkingBudget` is a genuine per-turn thinking budget (openai-compat `reasoning.max_tokens`) still decay. `taskBudget` (Anthropic adaptive-only whole-task cap, emitted as `output_config.task_budget`) never decays.
847
-
848
- **Reliability levers** are opt-in and live in `src/behaviors/`. `writeFileExistingPolicy` blocks full-file rewrites of existing files and points the model at `read_file` + `edit` / `multi_edit`; `readFileDefaults` sets omitted `read_file` slice defaults without overriding explicit tool-call args; `thinkingOverrunPolicy` queues a non-interrupting next-turn nudge when streamed thinking crosses a threshold; `textToolCallRescue` steers after a model writes a tool call as text; `unknownToolRecovery` queues closest currently-advertised tool-name suggestions without changing the unknown-tool error result; `progressNudges` emits bounded non-interrupting pivot nudges for broad stalls that exact repeat guards do not cover; `maxTurnsWarningMessage` customizes the existing max-turn proximity nudge.
849
-
850
- **Gate precedence**. Consumer hooks register first (agent-lifetime then per-run), framework gates install after — `allowed-tools → writeFileExistingPolicy → toolBudgets → repeatGuard → dedupTools → 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, repeat-guard can still escalate budget-blocked loops when configured to count blocked calls, 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).
851
-
852
- **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.
853
-
854
- **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.
855
-
856
- **`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.
857
-
858
- **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.
659
+ `run.behavior` > `agent.behavior` > defaults, field-by-field.
660
+
661
+ **Defaults:** unlimited turns, model-driven output tokens, 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: 0` (unlimited), `persistTurns: true`, `providerStreamStartTimeoutMs: 300000`, `providerStreamTimeoutMs: 1800000`, `mcpConnectTimeoutMs: 120000`. All other knobs below default to `undefined`/off unless noted. The chat-layer profiles (`BUILD_AGENT`/`PLAN_AGENT`) override several — see `docs/CHAT.md` § Agents.
662
+
663
+ - **`toolOutputBudget`** (off). Sums every result's `outputBytes` after `tool:transform`. On overshoot, appends a synthetic user message (`[Tool output budget exceeded: N bytes … cap: M]`) and fires `budget:exceeded` — the model self-compacts next turn instead of being cut.
664
+ - **`compactStrategy: 'tail' | CompactFunction'`** — client-side, wire-only trim for non-Anthropic providers. `'tail'` stubs `tool_result` blocks older than `compactKeepTurns` past a byte threshold; `session.turns` is untouched. Anthropic should prefer server-side `context-management-2025-06-27` (token-accurate). A custom function must preserve `tool_use ↔ tool_result` adjacency and the trailing user message.
665
+ - **`mediaKeepTurns`** — wire-only media age-out. Inline `image`/`document` blocks older than N messages become an `attachment://<hash>` marker on the wire; `session.turns` keeps the bytes, `read_file path="attachment://<hash>"` re-inlines. Chunk-snapped for cache stability; requires `read_file` registered to be actionable.
666
+ - **`autoCompact`** (`boolean | AutoCompactConfig`, off). At each turn boundary, once effective input (`input + cacheRead + cacheCreation`) crosses `threshold` (default `0.85`) of the context window, summarizes older turns and continues. Window source: `autoCompact.contextWindow` → `provider.meta.contextWindow`, else inert. `minGrowthFraction` (default `0.1`) stops post-compact thrashing; `maxFailures` (default `2`) disables it after consecutive summary failures. Chat profiles enable at `{ threshold: 0.85 }`.
667
+ - **`modelCatalog`** — override model metadata keyed `"<provider>:<modelId>"` or via `{ get, list }`. Resolution: run → agent `behavior.modelCatalog` → process-wide `setModelCatalog()` → bundled registry → local fallback tables, merged field-level. `list` returning `[]` empties a provider's picker; `get` returning `undefined` falls through. Unset by default.
668
+ - **`dedupReads` + `requireReadBeforeEdit`** share a per-session map keyed by canonical absolute path. `dedupReads` gates the short-circuit "unchanged since last read" reply; `requireReadBeforeEdit` rejects `edit`/`multi_edit` when no entry exists (shell reads don't register). Hash drift is anchor-tolerant — a still-verbatim `old_string` proceeds with a disclosure note, a fuzzy-only match is refused with "re-read the file", and consecutive refusals escalate to name the likely external writer. A `tool:gate` hook pre-hydrates the map from `session.turns` on resume.
669
+ - **`dedupTools`** generalizes the pattern via a per-tool hasher keeping only the most recent hash. Hasher-only form replays the prior result on hit (fires `tool:transform`+`tool:after`). `{ hasher, mode: 'block-after', threshold, reason?, scope? }` replays the first `threshold − 1` calls then blocks via `tool:gate` (`threshold` default `4`, clamps at `2`); `scope: 'run'` keeps parent/subagent counters separate. Never list tools with side effects or non-deterministic output.
670
+ - **`ToolContext.state`** (`src/tool-state.ts`) — durable key/value state that survives a durable-runtime replay where a journaled tool body doesn't re-execute. Reads are synchronous against a per-agent snapshot; writes stage as ops committed in call-index order. `AgentOptions.toolState` supplies the backing (unset ⇒ in-memory). A rejecting commit fires `tool-state:commit-failed` rather than failing the call; values cap at `maxValueBytes` (default 64 KiB JSON). See `docs/RESTATE.md#durable-tool-state`.
671
+ - **`toolBudgets`** caps per-tool calls per run. At `runToolCounts[name] >= max`, `onExceed: 'block'` refuses (`Blocked: <message>`), `'steer'` enqueues a deduped synthetic user message. Fires `tool-budget:exceeded` (distinct from byte-level `budget:exceeded`). Counts include dedup substitutes.
672
+ - **`thinkingDecay`** tapers `thinkingBudget` per run-relative turn. Struct form `{ afterTurn, factor, floor }` does geometric decay; function form `(turn, base) => number` allows arbitrary curves, clamped to `[0, base]`. Skipped for Anthropic's `thinking: 'adaptive'` (the budget caps the whole response envelope there); openai-compat's per-turn adaptive budget still decays. `taskBudget` never decays.
673
+ - **Reliability levers** (`src/behaviors/`, opt-in): `writeFileExistingPolicy` blocks full-file rewrites of existing files; `readFileDefaults` sets omitted `read_file` slice defaults; `thinkingOverrunPolicy` nudges next-turn on thinking overrun; `textToolCallRescue` steers after a model writes a tool call as text; `unknownToolRecovery` suggests closest advertised tool names; `progressNudges` nudges broad stalls repeat guards don't cover; `maxTurnsWarningMessage` customizes the max-turn proximity nudge.
674
+ - **Gate precedence.** Consumer hooks register first (agent-lifetime then per-run); framework gates install after: `allowed-tools → writeFileExistingPolicy → toolBudgets → repeatGuard → dedupTools → lazy-disclosure`. Each short-circuits on existing `ctx.block`/`ctx.result` — a policy gate always beats a consumer cache substitute, and lazy-disclosure runs last so skill refusal wins over "load schema first".
675
+ - **Tool-result persistence** (`persistThreshold` + `persistDir` + `persistExcludeTools`, off without `persistDir`). Oversize `tool_result` outputs become a `<persisted-output tool="…" bytes="…" path="…">` stub with a 2 KiB preview, substituted just after `tool:transform` so the stub — not the raw output — hits the cache prefix. `persistMaxBytes` caps the directory with oldest-first eviction; the just-written blob is never evicted in the same sweep.
676
+ - **Run-level budget circuit breaker** (`maxCostUsd` + `maxTotalTokens`, off). Checked after each turn. Trips `AgentBudgetExceededError` (`limit: 'cost' | 'tokens'`); the session run finalizes as `'aborted'`. Cost wins when both axes trip together. Token ledger sums `input + output` only (cache reads/creates are discounted). `maxTurns` is the count-based companion.
677
+ - **`maxConsecutivePauseTurns`** (default `5`, `0` disables) caps consecutive empty `pause_turn` recoveries (Anthropic 4.6+ streaming edge case). Any productive turn resets the counter; hitting the cap exits cleanly with no `agent:abort` — `finishReason` stays `'pause'`.
678
+ - **`retry`** (`{ maxAttempts, initialDelayMs, maxDelayMs }`, defaults `3`/`1000`/`30000`). Wraps `provider.stream()`; triggers only on a retryable `provider_error` **and** nothing streamed yet this turn. Fires `stream:retry` before each backoff sleep (full-jitter, `retry-after` header overrides); `stream:error` only on terminal failure. `maxAttempts: 1` disables it. Sits above the Anthropic SDK's own pre-stream HTTP retry, which can't replay a mid-stream error.
859
679
 
860
680
  ## Related
861
681
 
862
682
  - `docs/SKILL.md` — public API surface (createAgent, providers, presets, hooks, sessions, skills, MCP).
863
- - `docs/CHAT.md` — renderer-agnostic chat engine (`zidane/chat`).
683
+ - `docs/CHAT.md` — renderer-agnostic chat engine (`zidane/chat`), owns per-edit approval.
864
684
  - `docs/TUI.md` — OpenTUI terminal shell (`zidane/tui`).
685
+ - `docs/ARTIFACTS.md` — session artifacts model, producers, storage.
686
+ - `docs/RUN_IN_BACKGROUND.md` — background task mechanics.
687
+ - `docs/EXECUTION_CONTEXT.md` — `process` / `docker` / `e2b` / `daytona` execution contexts.
865
688
  - `docs/INTERACTIONS.md` — interactive tools (`present_plan`, `ask_user`) protocol.
866
689
  - `docs/RESTATE.md` — optional durable-execution adapter (`zidane/restate`).