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.
- package/README.md +18 -9
- package/dist/{acp-CZpX91YE.js → acp-Bw2b_kka.js} +3 -3
- package/dist/{acp-CZpX91YE.js.map → acp-Bw2b_kka.js.map} +1 -1
- package/dist/acp-cli.js +4 -4
- package/dist/acp.d.ts +1 -1
- package/dist/acp.js +1 -1
- package/dist/{agent-B1jsFaDq.js → agent-B_uQo4f1.js} +649 -48
- package/dist/agent-B_uQo4f1.js.map +1 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/{anthropic-Da39cE-d.js → anthropic-QbHqqTkV.js} +2 -2
- package/dist/{anthropic-Da39cE-d.js.map → anthropic-QbHqqTkV.js.map} +1 -1
- package/dist/{approvals-S100xf1d.js → approvals-B9aIQJRQ.js} +287 -45
- package/dist/approvals-B9aIQJRQ.js.map +1 -0
- package/dist/artifacts-CV2hUKnL.js +112 -0
- package/dist/artifacts-CV2hUKnL.js.map +1 -0
- package/dist/{auth-DfEWnkC7.js → auth-plw6npda.js} +19 -7
- package/dist/auth-plw6npda.js.map +1 -0
- package/dist/chat/pure.d.ts +3 -3
- package/dist/chat/pure.js +1 -1
- package/dist/chat.d.ts +6 -6
- package/dist/chat.js +5 -5
- package/dist/contexts/daytona.d.ts +1 -1
- package/dist/contexts/e2b.d.ts +1 -1
- package/dist/daemon-cli.js +1 -1
- package/dist/eval.d.ts +1 -1
- package/dist/eval.js +1 -1
- package/dist/extensions.d.ts +2 -2
- package/dist/extensions.js +2 -2
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +2 -2
- package/dist/{index-DFGECPfm.d.ts → index-Dc_zYpEu.d.ts} +351 -2
- package/dist/index-Dc_zYpEu.d.ts.map +1 -0
- package/dist/{index-DP7xYXr9.d.ts → index-Df2CH6bn.d.ts} +92 -7
- package/dist/index-Df2CH6bn.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +7 -6
- package/dist/{ipc-BLLscoIl.js → ipc-T_pPnHkp.js} +2 -2
- package/dist/{ipc-BLLscoIl.js.map → ipc-T_pPnHkp.js.map} +1 -1
- package/dist/ipc.d.ts +54 -6
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +3 -3
- package/dist/mcp.d.ts +1 -1
- package/dist/{oauth-C4GvY_p1.js → oauth-EIBUuSDK.js} +2 -2
- package/dist/{oauth-C4GvY_p1.js.map → oauth-EIBUuSDK.js.map} +1 -1
- package/dist/output/stream-json.d.ts +1 -1
- package/dist/output/terminal.d.ts +1 -1
- package/dist/plugins.d.ts +1 -1
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +2 -2
- package/dist/providers/anthropic.d.ts +1 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/arcee.d.ts +1 -1
- package/dist/providers/baseten.d.ts +1 -1
- package/dist/providers/cerebras.d.ts +1 -1
- package/dist/providers/local.d.ts +1 -1
- package/dist/providers/openai-compat.d.ts +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +1 -1
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +2 -2
- package/dist/{providers-BYgyPb15.js → providers-CoK1rZ1p.js} +2 -2
- package/dist/{providers-BYgyPb15.js.map → providers-CoK1rZ1p.js.map} +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +3 -3
- package/dist/restate.d.ts +1 -1
- package/dist/restate.js +1 -1
- package/dist/{server-C8gULJ0d.js → server-DXM8ZbGi.js} +64 -6
- package/dist/server-DXM8ZbGi.js.map +1 -0
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/session/sqlite.d.ts.map +1 -1
- package/dist/session/sqlite.js +117 -1
- package/dist/session/sqlite.js.map +1 -1
- package/dist/{session-K3icbPH9.js → session-DPZnncaQ.js} +60 -1
- package/dist/session-DPZnncaQ.js.map +1 -0
- package/dist/session.d.ts +2 -2
- package/dist/session.js +3 -2
- package/dist/skills.d.ts +1 -1
- package/dist/{tool-formatters-C-s_-u0v.d.ts → tool-formatters-BgdOYAy9.d.ts} +2 -2
- package/dist/{tool-formatters-C-s_-u0v.d.ts.map → tool-formatters-BgdOYAy9.d.ts.map} +1 -1
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +2 -2
- package/dist/{transcript-anchors-D1ToF-EW.d.ts → transcript-anchors-CtnwuQJM.d.ts} +3 -3
- package/dist/transcript-anchors-CtnwuQJM.d.ts.map +1 -0
- package/dist/tui.d.ts +3 -3
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +576 -102
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-CZLsNgw-.js → turn-operations-DkJ1R0p8.js} +4 -3
- package/dist/turn-operations-DkJ1R0p8.js.map +1 -0
- package/dist/{turn-operations-Bp5-BAki.d.ts → turn-operations-khAvr7KJ.d.ts} +5 -3
- package/dist/turn-operations-khAvr7KJ.d.ts.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/{xai-DILdFGzN.js → xai-CazjStOK.js} +3 -3
- package/dist/{xai-DILdFGzN.js.map → xai-CazjStOK.js.map} +1 -1
- package/docs/ACP.md +6 -0
- package/docs/ARCHITECTURE.md +179 -356
- package/docs/ARTIFACTS.md +130 -0
- package/docs/CHAT.md +426 -1312
- package/docs/EXECUTION_CONTEXT.md +46 -19
- package/docs/EXTENSIONS.md +234 -584
- package/docs/HEADLESS.md +5 -0
- package/docs/INTERACTIONS.md +21 -22
- package/docs/IPC.md +318 -335
- package/docs/PLUGINS.md +2 -0
- package/docs/README.md +57 -0
- package/docs/RESTATE.md +199 -491
- package/docs/RUN_IN_BACKGROUND.md +107 -558
- package/docs/SKILL.md +171 -238
- package/docs/TUI.md +213 -591
- package/package.json +1 -1
- package/dist/agent-B1jsFaDq.js.map +0 -1
- package/dist/approvals-S100xf1d.js.map +0 -1
- package/dist/auth-DfEWnkC7.js.map +0 -1
- package/dist/index-DFGECPfm.d.ts.map +0 -1
- package/dist/index-DP7xYXr9.d.ts.map +0 -1
- package/dist/server-C8gULJ0d.js.map +0 -1
- package/dist/session-K3icbPH9.js.map +0 -1
- package/dist/transcript-anchors-D1ToF-EW.d.ts.map +0 -1
- package/dist/turn-operations-Bp5-BAki.d.ts.map +0 -1
- package/dist/turn-operations-CZLsNgw-.js.map +0 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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["
|
|
24
|
-
R2 --> R3["
|
|
25
|
-
R3 --> R4["
|
|
26
|
-
R4 --> R5["
|
|
27
|
-
R5 --> R6["Build
|
|
28
|
-
R6 --> R7["
|
|
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["
|
|
34
|
+
P2 --> P4["finalizeSession() → session:end hook"]
|
|
35
35
|
P3 --> P4
|
|
36
|
-
P4 --> P5["
|
|
36
|
+
P4 --> P5["agent:done hook"]
|
|
37
|
+
P5 --> P6["Return AgentStats"]
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
Creation -->
|
|
40
|
-
FirstRun --> RunSetup
|
|
40
|
+
Creation --> RunSetup
|
|
41
41
|
RunSetup --> PostRun
|
|
42
42
|
|
|
43
43
|
style Creation fill:#1a1a2e,stroke:#e94560,color:#fff
|
|
44
|
-
style
|
|
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:
|
|
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
|
-
|
|
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 -->
|
|
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
|
|
167
|
-
|
|
168
|
-
**Per-call cancellation.** Each dispatch registers an `AbortController` in `LoopContext.toolCancels`
|
|
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.
|
|
171
|
-
|
|
172
|
-
|
|
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.**
|
|
181
|
-
- **Fleet vs. barrier.** Safe calls join a running
|
|
182
|
-
- **Order preserved at yield time.** Results
|
|
183
|
-
- **Sibling abort
|
|
184
|
-
- **Abort + steer
|
|
185
|
-
- **`shell` is conditional
|
|
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
|
-
|
|
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
|
|
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` |
|
|
196
|
-
| `shell` | Tail-priority truncation
|
|
197
|
-
| `write_file` | Reads existing content; returns `Created` / `Updated` / `No change needed: …` so the model detects no-ops without a separate read.
|
|
198
|
-
| `edit` | Fails clearly on non-unique `old_string`
|
|
199
|
-
| `multi_edit` | Sequential
|
|
200
|
-
| `grep` | Wraps `rg` when present
|
|
201
|
-
| `glob` | Default path intersects the pattern with `git ls-files
|
|
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
|
|
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.
|
|
246
|
-
2.
|
|
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
|
-
`
|
|
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' }`
|
|
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
|
|
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
|
|
318
|
-
| `cerebras` | Off
|
|
319
|
-
| `openai` (Codex) | Not affected — separate wire format
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
343
|
-
| `splitSystemPrompt(s)` |
|
|
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
|
|
346
|
-
| `
|
|
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
|
-
|
|
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
|
|
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
|
|
378
|
-
| `thinkingDecay` | Step form `{ afterTurn, floor }` | Every distinct budget
|
|
379
|
-
| `cacheTtl` | `'1h'` for interactive hosts
|
|
380
|
-
| `compactStrategy` / `mediaKeepTurns` | Keep windows equal, chunk-aligned
|
|
381
|
-
| `thinking` level | Constant for the whole run |
|
|
382
|
-
| `system:transform` handlers | Byte-stable output only |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
333
|
+
| either | `promptCacheKeyHeader: 'x-…'` additionally mirrors it into a header |
|
|
399
334
|
|
|
400
|
-
`promptCacheKey: true` derives
|
|
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
|
|
407
|
-
|
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
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(
|
|
527
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
558
|
-
|
|
559
|
-
**
|
|
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
|
|
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` —
|
|
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
|
|
594
|
-
model:fallback ← terminal failure advanced the run's
|
|
595
|
-
stream:error ← provider rejected terminally (omitted on user
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
625
|
-
|
|
626
|
-
system:before ← per-run only when run({ system }) is set
|
|
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 (
|
|
515
|
+
session:turns ← after turns persisted (SessionTurn[] of appended turns)
|
|
631
516
|
steer:inject ← if steering/follow-up message injected
|
|
632
|
-
agent:abort ←
|
|
633
|
-
agent
|
|
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 })`.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
|
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
|
|
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`)
|
|
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
|
-
- `
|
|
721
|
-
-
|
|
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
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
|
797
|
-
|
|
|
798
|
-
| `
|
|
799
|
-
| `
|
|
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
|
-
|
|
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
|
|
815
|
-
|
|
816
|
-
Defaults
|
|
817
|
-
|
|
818
|
-
**`toolOutputBudget`** (off
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
`
|
|
823
|
-
|
|
824
|
-
**`
|
|
825
|
-
|
|
826
|
-
**`
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
**`
|
|
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`).
|