zidane 7.0.15 → 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/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 +215 -623
- package/docs/HEADLESS.md +5 -0
- package/docs/INTERACTIONS.md +21 -22
- package/docs/IPC.md +318 -344
- 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 -593
- package/package.json +1 -1
package/docs/SKILL.md
CHANGED
|
@@ -7,7 +7,9 @@ paths: "src/**/*.ts, test/**/*.ts"
|
|
|
7
7
|
|
|
8
8
|
# Zidane Agent Framework
|
|
9
9
|
|
|
10
|
-
Minimal TypeScript agent loop built with Bun. Streams from an LLM, executes tools, loops until done.
|
|
10
|
+
Minimal TypeScript agent loop built with Bun. Streams from an LLM, executes tools, loops until done. This doc is the API reference for `createAgent`, providers, tools, hooks, sessions, skills, MCP, spawning. It owns the full `AgentHooks` table — other docs link here instead of restating it. Entry point: `src/agent.ts`, `src/index.ts`, `src/types.ts`.
|
|
11
|
+
|
|
12
|
+
Contents: [Core Concepts](#core-concepts) · [Creating an Agent](#creating-an-agent) · [Providers](#providers) · [Presets and Tools](#presets-and-tools) · [Hooks](#hooks) · [Agent State and Control](#agent-state-and-control) · [Background Tasks](#background-tasks) · [Sessions](#sessions) · [Sub-agent Spawning](#sub-agent-spawning) · [Interaction Tool](#interaction-tool) · [MCP Servers](#mcp-servers) · [Progressive Tool Disclosure](#progressive-tool-disclosure) · [Skills](#skills) · [Structured Output](#structured-output) · [Tracing](#tracing) · [Execution Contexts](#execution-contexts) · [Key Types](#key-types) · [File Layout](#file-layout)
|
|
11
13
|
|
|
12
14
|
## Core Concepts
|
|
13
15
|
|
|
@@ -95,7 +97,7 @@ await agent.run({
|
|
|
95
97
|
|
|
96
98
|
Text-encoded documents inline with attachment headers (universal); base64 documents (PDFs) need native support (Anthropic). Unsupported part types throw a clear error.
|
|
97
99
|
|
|
98
|
-
Precedence: `run.behavior` > `agent.behavior` > defaults. `prompt` is required unless the session already has turns. See `docs/ARCHITECTURE.md
|
|
100
|
+
Precedence: `run.behavior` > `agent.behavior` > defaults. `prompt` is required unless the session already has turns. See `docs/ARCHITECTURE.md` § Option Precedence.
|
|
99
101
|
|
|
100
102
|
## Providers
|
|
101
103
|
|
|
@@ -139,9 +141,9 @@ Provider params types: `AnthropicParams`, `OpenAIParams`, `OpenRouterParams`, `C
|
|
|
139
141
|
|
|
140
142
|
`openrouter`, `cerebras`, `arcee`, and `local` are thin wrappers over `openaiCompat` with vendor/runtime defaults pinned. For new OAI-compatible backends (Baseten, Fireworks, Groq), use `openaiCompat` directly.
|
|
141
143
|
|
|
142
|
-
Each factory also has a per-provider subpath (`zidane/providers/anthropic`, `zidane/providers/openai`, `zidane/providers/openai-compat`, `zidane/providers/{openrouter,xai,cerebras,baseten,arcee,local}`) so a bundle pulls only the provider it uses
|
|
144
|
+
Each factory also has a per-provider subpath (`zidane/providers/anthropic`, `zidane/providers/openai`, `zidane/providers/openai-compat`, `zidane/providers/{openrouter,xai,cerebras,baseten,arcee,local}`) so a bundle pulls only the provider it uses. Cost estimation loads pi-ai's price registry via a guarded dynamic import; providers not built on it (anthropic, openrouter, the openai-compat family) fall back to a local rate table instead of pulling the multi-MB registry. `zidane/providers/openai` keeps pi-ai since its client is built on it.
|
|
143
145
|
|
|
144
|
-
### Vision
|
|
146
|
+
### Vision and image tool results
|
|
145
147
|
|
|
146
148
|
`Provider.meta.capabilities` exposes `{ vision?, imageInToolResult? }`:
|
|
147
149
|
- `vision: false` — image blocks become `"[image omitted — model does not support vision]"` before reaching the provider.
|
|
@@ -168,7 +170,7 @@ Flatten via `toolResultToText(result)`. MCP image blocks are auto-normalized via
|
|
|
168
170
|
|
|
169
171
|
### Prompt caching
|
|
170
172
|
|
|
171
|
-
`behavior.cache` (default on). The provider inserts `cache_control: { type: 'ephemeral' }` on three stable prefixes — end of system, last tool, last content block of the last message — so shared prefixes hit cache across turns.
|
|
173
|
+
`behavior.cache` (default on). The provider inserts `cache_control: { type: 'ephemeral' }` on three stable prefixes — end of system, last tool, last content block of the last message — so shared prefixes hit cache across turns. See `docs/ARCHITECTURE.md` § Prompt Caching for the cache-break diagnosis flow.
|
|
172
174
|
|
|
173
175
|
| Provider | Behavior |
|
|
174
176
|
|---|---|
|
|
@@ -178,7 +180,7 @@ Flatten via `toolResultToText(result)`. MCP image blocks are auto-normalized via
|
|
|
178
180
|
| `cerebras` | Off. |
|
|
179
181
|
| `openai` (Codex) | Not affected (pi-ai wire format). |
|
|
180
182
|
|
|
181
|
-
Cache hits surface on `TurnUsage.cacheRead` / `cacheCreation`. Anthropic + OpenRouter populate them per turn.
|
|
183
|
+
Cache hits surface on `TurnUsage.cacheRead` / `cacheCreation`. Anthropic + OpenRouter populate them per turn. Live break/drift telemetry is a hook concern — see `cache:break` / `cache:drift` in the [Hook reference](#hook-reference).
|
|
182
184
|
|
|
183
185
|
### Provider error classification
|
|
184
186
|
|
|
@@ -226,30 +228,24 @@ Presets are plain `Partial<AgentOptions>` — inline literals work too, no `defi
|
|
|
226
228
|
|
|
227
229
|
### Tool aliasing
|
|
228
230
|
|
|
229
|
-
`toolAliases` maps canonical → wire names
|
|
230
|
-
|
|
231
|
-
- The alias is what the provider, model, and `displayName` see.
|
|
232
|
-
- The **canonical** name lives in `session.turns`, `ToolHookContext.name`, and dispatch lookup. Alias changes never desync persisted history.
|
|
233
|
-
- The factory rejects collisions (two canonicals → same alias).
|
|
234
|
-
|
|
235
|
-
Alias only when semantically equivalent. `shell → Bash` is safe; `list_files → Glob` is not. For distinct semantics, add a real implementation (e.g. the built-in `glob`).
|
|
231
|
+
`toolAliases` maps canonical → wire names, LLM-boundary-only: the alias is what the provider/model/`displayName` see; the **canonical** name lives in `session.turns`, `ToolHookContext.name`, and dispatch lookup, so alias changes never desync persisted history. The factory rejects collisions (two canonicals → same alias). Alias only when semantically equivalent (`shell → Bash` is safe; `list_files → Glob` is not — add a real implementation like the built-in `glob` instead). Full rewrite flow: `docs/ARCHITECTURE.md` § Tool Aliasing.
|
|
236
232
|
|
|
237
233
|
### Built-in tools
|
|
238
234
|
|
|
239
235
|
| Tool | Purpose |
|
|
240
236
|
|---|---|
|
|
241
|
-
| `shell` | Combined stdout+stderr tail-truncated at 32 KiB.
|
|
237
|
+
| `shell` | Combined stdout+stderr, tail-truncated at 32 KiB by default. Supports `run_in_background` — see [Background Tasks](#background-tasks). |
|
|
242
238
|
| `readFile` | Line range, default `offset=1, limit=2000`, 256 KiB cap. Paging footer; binary marker. |
|
|
243
239
|
| `writeFile` | Returns `Created` / `Updated` / `No change needed: …` for no-op detection. |
|
|
244
240
|
| `edit` | Surgical `old_string` → `new_string`. Clear errors on non-unique (unless `replace_all`) / not-found (with nearest-match preview). |
|
|
245
|
-
| `multiEdit` | Sequential edits to one file
|
|
241
|
+
| `multiEdit` | Sequential, best-effort per-hunk edits to one file — each step runs against the file as left by the previous APPLIED step. Per-edit approval is a host concern: the chat layer's `tool:gate` rebinds `ctx.input.edits` to the approved subset, then `tool:transform` appends the canonical `<edit-outcomes>` annotation (see `docs/CHAT.md`). |
|
|
246
242
|
| `listFiles` | Directory listing. |
|
|
247
|
-
| `glob` | `**`, `*`, `?` pattern matching; skips git-ignored files by default
|
|
248
|
-
| `grep` | ripgrep + `find`-via-`exec` fallback. Skips git-ignored files by default
|
|
249
|
-
| `spawn` | Sub-agent delegation (via `createSpawnTool`). |
|
|
250
|
-
| `createInteractionTool` | HITL factory (not a tool — instantiate per use). |
|
|
243
|
+
| `glob` | `**`, `*`, `?` pattern matching; skips git-ignored files by default via `git ls-files`, with a `Bun.Glob`/`fs.glob` fallback. |
|
|
244
|
+
| `grep` | ripgrep + `find`-via-`exec` fallback. Skips git-ignored files by default. `head_limit=250`, `offset` paginates. |
|
|
245
|
+
| `spawn` | Sub-agent delegation (via `createSpawnTool`). See [Sub-agent Spawning](#sub-agent-spawning). |
|
|
246
|
+
| `createInteractionTool` | HITL factory (not a tool — instantiate per use). See [Interaction Tool](#interaction-tool). |
|
|
251
247
|
|
|
252
|
-
`basicTools` (exported) contains `shell`, `readFile`, `writeFile`, `listFiles`, `edit`, `multiEdit`. The default `basic` preset adds `spawn`. `glob` / `grep` are opt-in.
|
|
248
|
+
`basicTools` (exported) contains `shell`, `readFile`, `writeFile`, `listFiles`, `edit`, `multiEdit`. The default `basic` preset adds `spawn`. `glob` / `grep` are opt-in. Default limits, truncation, and dedup behavior for each: `docs/ARCHITECTURE.md` § Tool Ergonomics Defaults.
|
|
253
249
|
|
|
254
250
|
Each tool is a `ToolDef`:
|
|
255
251
|
|
|
@@ -267,9 +263,7 @@ interface ToolDef {
|
|
|
267
263
|
* The built-in `spawn` does. Inert outside durable execution; under a durable
|
|
268
264
|
* runtime it is load-bearing — `wrapAgentTools` keeps such tools out of its
|
|
269
265
|
* journaling wrapper, because the child's own journaled calls cannot nest
|
|
270
|
-
* inside the outer tool's journal entry.
|
|
271
|
-
* and the invocation fails on its first retry. See `docs/RESTATE.md`
|
|
272
|
-
* § Subagents.
|
|
266
|
+
* inside the outer tool's journal entry. See `docs/RESTATE.md` § Subagents.
|
|
273
267
|
*/
|
|
274
268
|
runsNestedAgent?: boolean
|
|
275
269
|
}
|
|
@@ -277,19 +271,13 @@ interface ToolDef {
|
|
|
277
271
|
|
|
278
272
|
`ToolContext` exposes `provider`, `signal`, `execution`, `handle`, `hooks`, `turnId`, `callId`, `runId?`, `parentRunId?`, `parentCallId?`, `session?`, `readState?`, `clock?`, `depth?`, plus preset fields (`name`, `system`, `tools`, `toolAliases`, `mcpServers`, `skills`, `behavior`) so `spawn` can inherit.
|
|
279
273
|
|
|
280
|
-
`clock` is the run's time / UUID source (`AgentClock`) — prefer it over `Date.now()` / `crypto.randomUUID()` for anything
|
|
274
|
+
`clock` is the run's time / UUID source (`AgentClock`) — prefer it over `Date.now()` / `crypto.randomUUID()` for anything persisted or shown to the model, because a durable host replaces it with a journaled one so those values replay byte-identically. Durable adapters set `AgentClock.journaled: true`; consumers read it to avoid a wall-clock timer inside a journaled region (`spawn` switches its per-child timeout to a turn-boundary check on that signal).
|
|
281
275
|
|
|
282
276
|
Returns `string` (common) or `ToolResultContent[]` (images / mixed content). Routed natively via `tool_result.content` when supported, or via companion user message on plain Chat Completions. Flatten via `toolResultToText(result)`.
|
|
283
277
|
|
|
284
278
|
### Per-tool concurrency
|
|
285
279
|
|
|
286
|
-
The unified dispatcher walks tool calls in submission order
|
|
287
|
-
|
|
288
|
-
- **Safe siblings fan out** — concurrent-safe tools form a fleet that runs in parallel up to `behavior.maxConcurrentTools` (default `10`).
|
|
289
|
-
- **Unsafe tools act as barriers** — the dispatcher drains the in-flight fleet, runs the unsafe call alone, then unblocks the queue. Subsequent safe calls re-form a new fleet behind it.
|
|
290
|
-
- **Results yield in submission order** regardless of completion order — the model sees a deterministic transcript.
|
|
291
|
-
|
|
292
|
-
Built-in annotations (mirrors the convention that reads parallelize, writes serialize):
|
|
280
|
+
The unified dispatcher walks tool calls in submission order: concurrent-safe tools fan out up to `behavior.maxConcurrentTools` (default `10`); an unsafe call drains the in-flight fleet, runs alone as a barrier, then unblocks the queue. Results yield in submission order regardless of completion order. Scheduler algorithm: `docs/ARCHITECTURE.md` § Concurrency Scheduler.
|
|
293
281
|
|
|
294
282
|
| Concurrency-safe | Barrier (default) |
|
|
295
283
|
|---|---|
|
|
@@ -298,21 +286,15 @@ Built-in annotations (mirrors the convention that reads parallelize, writes seri
|
|
|
298
286
|
|
|
299
287
|
`shell.isConcurrencySafe` parses `input.command`: `ls`, `cat`, `pwd`, `git status`, `rg`, `echo`, etc. fan out (full allow-list in `tools/shell.ts → SHELL_READ_ONLY_COMMANDS` + `GIT_READ_ONLY_SUBCOMMANDS`). Anything mutating, any redirect (`>`, `>>`, `|`), any command chain (`&&`, `||`, `;`), and any subshell (`$(…)`, `` `…` ``) barriers.
|
|
300
288
|
|
|
301
|
-
**Cascade-cancel
|
|
289
|
+
**Cascade-cancel.** A `shell` error inside a concurrent fleet cancels its in-flight siblings via the per-batch `siblingAbort` controller. Other tool errors are isolated (one read's failure doesn't kill another read). Cancelled siblings get a `Cancelled: a sibling \`shell\` call …` tool_result so the model can retry independently.
|
|
302
290
|
|
|
303
|
-
**Authoring custom tools
|
|
304
|
-
|
|
305
|
-
- The tool only reads from disk / network / catalog.
|
|
306
|
-
- The tool doesn't mutate any shared state (session metadata, files, in-memory caches your other tools read).
|
|
307
|
-
- Parallel invocations against different inputs produce the same outputs as sequential invocations.
|
|
308
|
-
|
|
309
|
-
Use `isConcurrencySafe: (input) => …` when safety depends on the call's arguments (the `shell` pattern).
|
|
291
|
+
**Authoring custom tools.** Default is fail-closed (`false`) — a custom tool serializes unless you opt in. Opt in via `isConcurrencySafe: true` only when the tool reads without mutating shared state and parallel calls with different inputs produce the same outputs as sequential ones. Use a predicate (`(input) => …`) when safety depends on the call's arguments (the `shell` pattern).
|
|
310
292
|
|
|
311
293
|
### Tool argument validation (auto-coerce)
|
|
312
294
|
|
|
313
|
-
`validateToolArgs(input, schema)` runs between `tool:gate` and `tool:before`. Missing/null required fields fail. Top-level coercions normalize
|
|
295
|
+
`validateToolArgs(input, schema)` runs between `tool:gate` and `tool:before`. Missing/null required fields fail. Top-level coercions normalize common OSS-model mistakes (`"true"`/`"yes"`/`"1"` → `true`, numeric strings → numbers, JSON strings → arrays/objects). `execute` always receives the **coerced** input. `validation:reject` fires only when no type reconciles.
|
|
314
296
|
|
|
315
|
-
Array recursion: properties declared `{ type: 'array', items: {
|
|
297
|
+
Array recursion: properties declared `{ type: 'array', items: {...}, maxItems?, minItems? }` walk each item against `items` (one level of nesting). Items that can't be coerced are **dropped** rather than failing the whole call; original indexes surface on `ValidationResult.droppedItems[fieldName]`. `maxItems` truncates and reports the discarded tail.
|
|
316
298
|
|
|
317
299
|
```ts
|
|
318
300
|
import { validateToolArgs } from 'zidane'
|
|
@@ -325,102 +307,129 @@ const { valid, coercedInput, coercions } = validateToolArgs(
|
|
|
325
307
|
|
|
326
308
|
## Hooks
|
|
327
309
|
|
|
328
|
-
Hooks fire at every lifecycle point via [hookable](https://github.com/unjs/hookable). Awaited in registration order; ctx is shared per firing (last-writer wins). Hooks marked **mutable** influence downstream behavior; others are observational. Framework gates (`allowed-tools`, `writeFileExistingPolicy`, `toolBudgets`, `repeatGuard`, `dedupTools`, lazy disclosure) short-circuit on existing `block` / `result`, so a policy gate always beats a consumer cache substitute regardless of registration order.
|
|
310
|
+
Hooks fire at every lifecycle point via [hookable](https://github.com/unjs/hookable). Awaited in registration order; ctx is shared per firing (last-writer wins). Hooks marked **mutable** influence downstream behavior; others are observational. Framework gates (`allowed-tools`, `writeFileExistingPolicy`, `toolBudgets`, `repeatGuard`, `dedupTools`, lazy disclosure) short-circuit on existing `block` / `result`, so a policy gate always beats a consumer cache substitute regardless of registration order. Firing order across a turn/run: `docs/ARCHITECTURE.md` § Hook Firing Order.
|
|
329
311
|
|
|
330
|
-
For post-edit validation, use `createMutationValidationHooks()
|
|
312
|
+
For post-edit validation, use `createMutationValidationHooks()`. It observes successful `tool:after.outcome` values (`created`, `updated`, `edited`) and batches them at `tool-results:after`; hosts own the validator command and can call `agent.followUp(failure.message)` from `onFailure`. It does not wrap or replace `write_file` / `edit` / `multi_edit`.
|
|
331
313
|
|
|
332
314
|
### Hook reference
|
|
333
315
|
|
|
316
|
+
Every event in `AgentHooks` (`src/agent.ts`), one row each. `child:*` bubble events are listed separately below the table.
|
|
317
|
+
|
|
334
318
|
| Hook | Fires | Mutable | Key ctx fields |
|
|
335
319
|
|---|---|---|---|
|
|
336
|
-
| `
|
|
337
|
-
| `
|
|
338
|
-
| `
|
|
339
|
-
| `
|
|
340
|
-
| `
|
|
341
|
-
| `
|
|
342
|
-
| `
|
|
343
|
-
| `
|
|
344
|
-
| `
|
|
345
|
-
| `
|
|
346
|
-
| `
|
|
347
|
-
| `
|
|
348
|
-
| `
|
|
349
|
-
| `
|
|
350
|
-
| `
|
|
351
|
-
| `
|
|
352
|
-
| `
|
|
353
|
-
| `
|
|
354
|
-
| `
|
|
355
|
-
| `
|
|
356
|
-
| `
|
|
357
|
-
| `
|
|
358
|
-
| `
|
|
359
|
-
| `
|
|
360
|
-
| `tool-results:after` | After the user-role tool-results turn is pushed (persistence seam — write the pair together) | — | `turn, turnId, message, results: readonly ToolResult[]` |
|
|
361
|
-
| `tool:gate` | Before each tool call | **yes** (`block, reason, result?`) | `turnId, callId, name, displayName, input, runToolCounts` — set `block` to refuse with `tool_result.content = "Blocked: <reason>"` + `is_error: true` (pinned contract); set `result` to substitute and skip execute (fires `tool:transform` + `tool:after`); `block` wins over `result` |
|
|
320
|
+
| `system:before` | `run({ system })` set a run-level system prompt | — (observational despite the name; the loop reads `run({ system })` directly) | `system` |
|
|
321
|
+
| `agent:start` | Once per `agent.run()`, after `session:start`, before the first `turn:before` | — | `runId, parentRunId?, depth, agentName?, providerName?, startedAt, tracingContext?` |
|
|
322
|
+
| `turn:before` | Before each turn's `provider.stream()` call | **yes** (`options`, last-mile escape hatch — prefer `context:transform` for `messages`) | `turn, turnId, options` |
|
|
323
|
+
| `cache:break` | Turn read back materially less cached prefix than the previous turn (prompt-cache break); only fires when `installCacheBreakLogger` is installed | — | `turn, turnId, model, changed, details` (see `CacheBreakInfo`) |
|
|
324
|
+
| `cache:drift` | `turn:before`'s outgoing request diverges from the previous completed turn's; predictive counterpart of `cache:break` | — | `turn, turnId, model, changed: CacheDimensionName[], details` (see `CacheDriftInfo`) |
|
|
325
|
+
| `turn:after` | After each assistant turn (always, even on error) | — | `turn, turnId, usage, message, aborted?, toolCounts: { turn, run }, cumulativeUsage` |
|
|
326
|
+
| `tool-results:after` | After the user-role tool-results turn is pushed (persistence seam — write tool_use/tool_result pairs together) | — | `turn, turnId, message, results: readonly ToolResult[]` |
|
|
327
|
+
| `stream:start` | Immediately before `provider.stream()` | — | `startedAt` |
|
|
328
|
+
| `stream:thinking` | Each reasoning chunk | — | `delta, thinking` |
|
|
329
|
+
| `stream:text` | Each text chunk | — | `delta, text` |
|
|
330
|
+
| `stream:end` | Text stream complete (only if text present) | — | `text` |
|
|
331
|
+
| `stream:server_tool_use` | Provider-executed tool invocation closes (e.g. Anthropic `web_search_20250305`) | — | `id, name, input` |
|
|
332
|
+
| `stream:server_tool_result` | Matching provider-executed tool result closes | — | `toolUseId, toolName, content` |
|
|
333
|
+
| `stream:error` | Provider stream rejects before `stream:end` | — | `err, statusCode?, requestId?` — never fires on user aborts (those land on `agent:abort`) |
|
|
334
|
+
| `stream:retry` | Loop caught a retryable provider error, about to sleep and re-issue | — | `attempt, nextAttempt, maxAttempts, delayMs, err, statusCode?, requestId?` |
|
|
335
|
+
| `model:fallback` | Stream failed terminally on the active target; loop advances to the next entry of the run's fallback chain | — | `from: {provider,model}, to: {provider,model}, targetIndex, err` |
|
|
336
|
+
| `context:overflow` | Loop caught `context_exceeded`, ran emergency in-loop compaction, is re-issuing the stream | — | `attempt, err` |
|
|
337
|
+
| `compact:start` | `behavior.autoCompact` threshold crossed, about to run the summary call | — | `runId, sessionId?, turn, reason: 'auto', usedFraction, effectiveWindow` |
|
|
338
|
+
| `compact:progress` | Throttled, while the compaction summary streams | — | `+ estOutputTokens` |
|
|
339
|
+
| `compact:end` | Exactly once per `compact:start`, on success or failure | — | `+ status: 'success' \| 'error'` (success: replaced/dropped/restored counts + `effectiveTokens`; error: `error`) |
|
|
340
|
+
| `history:repair` | After a provider stream settles when the loop scrubbed provider-rejected reasoning state (e.g. Anthropic signature-400 poisoning) | — | `reason: 'invalid-anthropic-thinking-signature', runId, sessionId?, turnIds, removedBlocks` |
|
|
341
|
+
| `oauth:refresh` | OAuth token refreshed | — | `provider, providerId, source: 'params'\|'file', previousCredentials, credentials` |
|
|
342
|
+
| `content-ref:resolve` | Wire assembly sees a block carrying `ref`; resolves persisted content refs into provider-bound bytes | **yes** (`data`) | `block, data?, childId?, depth?` |
|
|
343
|
+
| `tool:gate` | Before each tool call | **yes** (`block, reason, result?`) | `turnId, callId, name, displayName, input, runToolCounts` — `block` refuses with `tool_result.content = "Blocked: <reason>"` + `is_error: true`; `result` substitutes and skips execute; `block` wins over `result` |
|
|
362
344
|
| `tool:before` | Tool execution starts | — | `turnId, callId, name, displayName, input, runToolCounts, priorContent?` |
|
|
345
|
+
| `tool:dispatched` | Fires for every dispatch path (gate-block/gate-substitute/unknown/invalid-input/execute), before `tool:before` on the execute path | — | `outcome: 'execute'\|'gate-substitute'\|'gate-block'\|'unknown'\|'invalid-input', reason?, runToolCounts` |
|
|
363
346
|
| `tool:unknown` | Model called an unregistered tool | **yes** (`result?, suppressError`) | `turnId, callId, name, displayName, input` |
|
|
364
|
-
| `validation:reject` | Tool args failed validation (after auto-coerce) | — | `+ reason: string, schema
|
|
365
|
-
| `validation:coerce` | Tool args were auto-healed (only
|
|
347
|
+
| `validation:reject` | Tool args failed validation (after auto-coerce) | — | `+ reason: string, schema` |
|
|
348
|
+
| `validation:coerce` | Tool args were auto-healed (only when fields changed) | — | `+ coercions: readonly string[], schema` |
|
|
366
349
|
| `mcp:tool:gate` | Before each MCP call | **yes** (`block, reason, result?`) | `turnId, callId, server, tool, displayName, input` |
|
|
367
350
|
| `mcp:tool:before` | MCP execution starts | — | `turnId, callId, server, tool, displayName, input` |
|
|
368
|
-
| `mcp:tool:transform` | After MCP result | **yes** (`result`) | `+ result
|
|
369
|
-
| `mcp:tool:after` | MCP complete | — | `+ result
|
|
351
|
+
| `mcp:tool:transform` | After MCP result | **yes** (`result`) | `+ result, outputBytes` |
|
|
352
|
+
| `mcp:tool:after` | MCP complete | — | `+ result, outputBytes` |
|
|
370
353
|
| `mcp:tool:error` | MCP error | — | `+ error` |
|
|
371
|
-
| `tool:transform` | After tool result | **yes** (`result`, `isError`) | `+ result
|
|
372
|
-
| `
|
|
354
|
+
| `tool:transform` | After tool result | **yes** (`result`, `isError`) | `+ result, isError, metadata, outputBytes, coercions?` |
|
|
355
|
+
| `artifact:recorded` | Once per artifact the session recorded from a tool result's `metadata.artifacts` namespace | — | `runId, turnId, callId, name, artifact: SessionArtifact` — see `docs/ARTIFACTS.md` |
|
|
356
|
+
| `tool-state:commit-failed` | A settled call's durable state (`ToolContext.state`) could not be committed to `AgentOptions.toolState` | **yes** (`handled`) | `runId, callId, toolName, ops: readonly ToolStateOp[], error, handled?` — set `handled = true` to suppress the default stderr log |
|
|
357
|
+
| `tool:after` | Tool complete | — | `+ result, outputBytes, runToolCounts, outcome?` |
|
|
373
358
|
| `tool:error` | Tool error | **yes** (`result?`) | `+ error` — mutate `result` to substitute the payload sent back to the model |
|
|
374
|
-
| `tool:cancelled` | Per-call cancel via `agent.cancelTool(callId, reason?)`
|
|
375
|
-
| `
|
|
376
|
-
| `
|
|
377
|
-
| `execution:ready` | Execution handle bound (first run's lazy `spawn()`, or immediately when `AgentOptions.handle` was injected) | — | `handle, spawned` — fires once per agent; `spawned: false` for an injected handle. Durable hosts journal the minted handle id here for re-attach on the next invocation |
|
|
378
|
-
| `usage` | Per-turn usage | — | `turn, turnId, usage, totalIn, totalOut` |
|
|
379
|
-
| `output` | Structured output extracted | — | `output, schema` |
|
|
380
|
-
| `budget:exceeded` | Per-turn tool-output bytes over `behavior.toolOutputBudget` | — | `turn, turnId, bytes, budget` |
|
|
381
|
-
| `tool-budget:exceeded` | Per-tool call cap from `behavior.toolBudgets` tripped | — | `tool, count, max, turnId, mode: 'steer' \| 'block'` |
|
|
382
|
-
| `repeat-guard:exceeded` | Repeat detector from `behavior.repeatGuard` tripped | — | `tool, count, threshold, turnId, action: 'block' \| 'steer' \| 'abort'` — `'steer'` means the tool ran and got steering appended; `'abort'` fires before the run is aborted via the agent's `AbortController` |
|
|
359
|
+
| `tool:cancelled` | Per-call cancel via `agent.cancelTool(callId, reason?)` | — | `+ reason: string, runToolCounts` — wire result is `TOOL_USE_CANCELLED_MESSAGE`; `tool:transform` / `tool:after` do **not** fire |
|
|
360
|
+
| `context:transform` | Before each LLM call | **yes** (`messages`) | `messages` |
|
|
361
|
+
| `system:transform` | Per-request system-prompt mutation (after `context:transform`) | **yes** (`system`) | `system, messages (readonly), turn, turnId, session?` |
|
|
383
362
|
| `steer:inject` | Steering/follow-up injected | — | `message` |
|
|
384
|
-
| `session:turns` | Turns persisted | — | `sessionId, turns, count` |
|
|
385
|
-
| `session:save` | Session store write | — | `sessionId` |
|
|
386
|
-
| `session:meta` | Session metadata write | — | `sessionId, key: string, value: unknown` |
|
|
387
363
|
| `spawn:before`, `spawn:complete`, `spawn:error` | Sub-agent lifecycle | — | `id, task, callId?, stats?, error?` (`callId` = the spawn call's own id, i.e. the child's `parentCallId`) |
|
|
388
|
-
| `
|
|
364
|
+
| `mcp:connect` | Server's client is live (bootstrap, or first `tools/call` for `lazyConnect`) | — | `name, transport, tools, lazy?` |
|
|
365
|
+
| `mcp:error` | Fatal MCP connection failure | — | `name, error` |
|
|
366
|
+
| `mcp:warn` | Diagnostic line from a server | — | `name, message` |
|
|
367
|
+
| `mcp:close` | MCP connection closed | — | `name` |
|
|
368
|
+
| `mcp:bootstrap:start` | Per server, before connect + listTools | — | `name, transport` |
|
|
369
|
+
| `mcp:bootstrap:end` | Per server, after connect attempt (always fires) | — | `name, transport, durationMs, ok` + (`toolCount, lazy?, cached?` \| `error`) |
|
|
370
|
+
| `mcp:bootstrap:settled` | Once per bootstrap batch, after every server's attempt settled | — | `results: ReadonlyArray<{name, transport, durationMs} & ({ok:true, toolCount, lazy?}\|{ok:false, skipped?, error?})>`, sorted by name |
|
|
371
|
+
| `mcp:tools:list` | Per server, after `listTools()` (or `cachedTools` resolution), before filtering | — | `name, transport, tools: McpToolSchema[]` |
|
|
372
|
+
| `mcp:auth:required` | Server needs OAuth login (no tokens / auto-promoted on 401+RFC9728) | — | `name, transport, reason: 'no-tokens'\|'auto-promoted'` |
|
|
373
|
+
| `mcp:auth:url`, `mcp:auth:success`, `mcp:auth:error` | Interactive OAuth flow lifecycle | — | `name` (+ `url` \| `error`) |
|
|
374
|
+
| `mcp:tools:filter` | Per server, after config-side allow/deny/predicate, before registration | **yes** (`tools[]`) | `server, transport, tools: Array<{name, description?, inputSchema?}>` |
|
|
375
|
+
| `background:start` | Background task spawned via `shell({ run_in_background: true })` | — | `taskId, pid, command, cwd, outputPath, startedAt` |
|
|
376
|
+
| `background:exit` | Background task terminated (natural exit, `shell_kill`, or `agent.destroy()` teardown) | — | `taskId, status: 'exited'\|'killed', exitCode, signal?, outputPath, durationMs, command` — at most once per task |
|
|
377
|
+
| `background:stall` | Running task produced no output for `behavior.backgroundStallWatchdogMs` | — | `taskId, command, outputPath, stalledForMs, bytesWritten` — one-shot per quiet period, re-arms after fresh output |
|
|
378
|
+
| `background:reconcile` | Merged push+pull background-exit list, before rendering `<task-notification>` blocks | **yes** (`exits`) | `exits: TaskExitInfo[]` — durable hosts journal it here |
|
|
379
|
+
| `background:reassign` | A destroyed subagent's still-running task was promoted to this agent's handle; fires on the NEW owner's bus | — | `taskId, fromHandleId, toHandleId, childId?, pid, command, outputPath, startedAt` — see [RUN_IN_BACKGROUND.md](./RUN_IN_BACKGROUND.md) |
|
|
380
|
+
| `execution:ready` | Execution handle bound (first run's lazy `spawn()`, or immediately for an injected handle) | — | `handle, spawned` — fires once per agent |
|
|
381
|
+
| `skills:resolve` | Once per agent, after discovery (lazy on first `warmup()`/`run()`/`activateSkill()`, eager when `eager: true`) | — | `skills` |
|
|
382
|
+
| `skills:catalog` | Once per agent, after catalog build (same timing as `skills:resolve`) | **yes** (`catalog`) | `catalog, skills` |
|
|
383
|
+
| `skills:activate` | Skill activated (via `skills_use` / `activateSkill` / session resume) | — | `skill, via: 'model'\|'explicit'\|'resume'` |
|
|
384
|
+
| `skills:deactivate` | Skill deactivated | — | `skill, reason: 'run-end'\|'explicit'\|'reset'\|'model'` — `'model'` when the model calls `skills_use({ mode: 'deactivate' })` |
|
|
385
|
+
| `pairing:repair` | Defensive tool_use/tool_result pairing pass repaired corrupted history | — | `mode, callId?, messageIndex, turnId?` |
|
|
386
|
+
| `tracing:redact` | Privacy seam before an observability adapter attaches a payload as a span attribute / log line | **yes** (`redacted`) | `kind: 'system'\|'prompt'\|'tool-input'\|'tool-result'\|'mcp-tool-input'\|'mcp-tool-result'\|'stream-text'\|'stream-thinking', value, redacted, meta?` — synchronous only |
|
|
387
|
+
| `usage` | Per-turn usage | — | `turn, turnId, usage, totalIn, totalOut` |
|
|
388
|
+
| `output` | Structured output extracted | — | `output, schema` |
|
|
389
|
+
| `budget:exceeded` | Per-turn tool-output bytes over `behavior.toolOutputBudget` (measured post-`tool:transform`) | — | `turn, turnId, bytes, budget` |
|
|
390
|
+
| `tool-budget:exceeded` | Per-tool call cap from `behavior.toolBudgets` tripped | — | `tool, count, max, turnId, mode: 'steer'\|'block'` |
|
|
391
|
+
| `repeat-guard:exceeded` | Repeat detector from `behavior.repeatGuard` tripped | — | `tool, count, threshold, turnId, action: 'block'\|'steer'\|'abort'` |
|
|
389
392
|
| `agent:abort` | `agent.abort()` called | — | `{}` |
|
|
390
393
|
| `agent:done` | Run finished (all exit paths) | — | `AgentStats` |
|
|
394
|
+
| `replay:done` | Durable-runtime adapter (e.g. `zidane/restate`) finished replaying/executing one handler invocation | — | `{}` — never fired by core; at most once per invocation |
|
|
395
|
+
| `session:start`, `session:end` | Run boundaries (always fire) | — | `sessionId, runId` + (`prompt` \| `status, turnRange`) |
|
|
396
|
+
| `session:turns` | Turns persisted | — | `sessionId, turns, count` |
|
|
397
|
+
| `session:meta` | Session metadata write | — | `sessionId, key, value` |
|
|
398
|
+
| `session:save` | Session store write | — | `sessionId` |
|
|
391
399
|
|
|
392
|
-
|
|
400
|
+
Every `tool:*` / `mcp:tool:*` row above also omits four optional identity fields for readability — see [Identity fields](#identity-fields-on-tool-hook-contexts).
|
|
393
401
|
|
|
394
|
-
|
|
402
|
+
### Mutation contract
|
|
395
403
|
|
|
396
404
|
`outputBytes` is the wire size — **pre-mutation** on `*:transform` (truncation handlers decide), **post-mutation** on `*:after` (what the model sees). Reproduce via `toolOutputByteLength(content)`. `ctx.coercions` is **omitted** on `tool:before` / `tool:after` / `tool:transform` when no coercion happened; guard with `if (ctx.coercions)`.
|
|
397
405
|
|
|
398
406
|
### Identity fields on tool hook contexts
|
|
399
407
|
|
|
400
|
-
Every `tool:*` and `mcp:tool:*` hook ctx additionally carries four optional identity fields (omitted from the
|
|
408
|
+
Every `tool:*` and `mcp:tool:*` hook ctx additionally carries four optional identity fields (omitted from the table above to keep it readable):
|
|
401
409
|
|
|
402
|
-
- `runId?: string` — the `SessionRun.id` this call belongs to.
|
|
403
|
-
- `parentRunId?: string` — set when the call's agent is a subagent (mirrors `SessionRun.parentRunId`); absent on top-level runs.
|
|
404
|
-
- `parentCallId?: string` — the `callId` of the `spawn` call that created this run; absent on top-level runs
|
|
410
|
+
- `runId?: string` — the `SessionRun.id` this call belongs to. Disambiguates calls across parallel runs / subagent trees without subscribing to `child:tool:*`.
|
|
411
|
+
- `parentRunId?: string` — set when the call's agent is a subagent (mirrors `SessionRun.parentRunId`); absent on top-level runs. Only wired when the spawn shares the parent's session (`persist`) — for grouping that must hold on every subagent, use `parentCallId`.
|
|
412
|
+
- `parentCallId?: string` — the `callId` of the `spawn` call that created this run; absent on top-level runs, set for every child the built-in `spawn` tool starts. Not implied by `depth >= 1` (a host that spawns via `agent.run({ depth })` directly must also pass `AgentRunOptions.parentCallId`); at depth ≥ 2 it names a call the top-level listener only saw as `child:tool:*`.
|
|
405
413
|
- `depth?: number` — subagent depth, 0 = top-level. Mirrors `ToolContext.depth`.
|
|
406
414
|
|
|
407
|
-
All four land on `ToolContext`
|
|
415
|
+
All four also land on `ToolContext` (`ctx.runId` / `ctx.parentRunId` / `ctx.parentCallId` / `ctx.depth`), centralized in `buildToolHookBase` so new tool-related hooks inherit them automatically.
|
|
408
416
|
|
|
409
417
|
### Child (sub-agent) bubble events
|
|
410
418
|
|
|
411
|
-
`spawn`'s stream/tool/turn events bubble to the parent with `childId` + `depth`:
|
|
419
|
+
`spawn`'s stream/tool/turn/artifact events bubble to the parent with `childId` + `depth`:
|
|
412
420
|
|
|
413
421
|
```
|
|
414
422
|
child:stream:text / child:stream:thinking / child:stream:end
|
|
415
|
-
child:stream:server_tool_use / child:stream:server_tool_result
|
|
423
|
+
child:stream:server_tool_use / child:stream:server_tool_result / child:stream:error / child:stream:retry
|
|
416
424
|
child:tool:gate / child:mcp:tool:gate ← mutable: block/reason/result propagate to the child
|
|
417
425
|
child:tool:transform ← mutable: parent can rewrite the child's tool_result
|
|
418
|
-
child:tool:before / child:tool:after / child:tool:error / child:tool:cancelled
|
|
419
|
-
child:background:start / child:background:exit
|
|
426
|
+
child:tool:before / child:tool:dispatched / child:tool:after / child:tool:error / child:tool:cancelled
|
|
427
|
+
child:background:start / child:background:exit / child:background:reassign
|
|
420
428
|
child:turn:after
|
|
429
|
+
child:artifact:recorded
|
|
421
430
|
```
|
|
422
431
|
|
|
423
|
-
Render nested activity without listening on the child instance. The `child:*:gate` and `child:tool:transform` events
|
|
432
|
+
Render nested activity without listening on the child instance. The `child:*:gate` and `child:tool:transform` events share the same `ctx` reference the child's loop awaits on (`BUBBLED_MUTABLE_EVENTS` in `src/tools/spawn.ts`), so a parent gate handler can refuse / substitute / annotate a child's tool call without registering on the child agent. `child:background:reassign` fires when a grandchild's background tasks get promoted to the child's handle on the grandchild's destroy — the chain runs at each spawn boundary until the top-level parent owns every task.
|
|
424
433
|
|
|
425
434
|
### Hook recipes
|
|
426
435
|
|
|
@@ -453,7 +462,7 @@ agent.hooks.hook('context:transform', (ctx) => {
|
|
|
453
462
|
})
|
|
454
463
|
```
|
|
455
464
|
|
|
456
|
-
`mcp:tool:transform`, `tool:unknown`, and `context:transform` are the highest-leverage entries for cases
|
|
465
|
+
`mcp:tool:transform`, `tool:unknown`, and `context:transform` are the highest-leverage entries for cases the framework doesn't auto-handle. Most production agents end up with one of each.
|
|
457
466
|
|
|
458
467
|
## Agent State and Control
|
|
459
468
|
|
|
@@ -470,7 +479,7 @@ await agent.waitForIdle()
|
|
|
470
479
|
await agent.destroy() // clean up execution + MCP
|
|
471
480
|
```
|
|
472
481
|
|
|
473
|
-
`cancelTool` returns `true` when a live call was flipped, `false` otherwise (idempotent). The matching call's `ctx.signal` aborts, `tool:cancelled` fires, and the wire result becomes `TOOL_USE_CANCELLED_MESSAGE` with `isError: true` — other parallel calls in the same batch keep running
|
|
482
|
+
`cancelTool` returns `true` when a live call was flipped, `false` otherwise (idempotent). The matching call's `ctx.signal` aborts, `tool:cancelled` fires, and the wire result becomes `TOOL_USE_CANCELLED_MESSAGE` with `isError: true` — other parallel calls in the same batch keep running. Distinguished from `abort()` (whole-run abort, batch-layer `INTERRUPT_MESSAGE_FOR_TOOL_USE`) and `TOOL_USE_SKIPPED_MESSAGE` (steered out) so consumers can split the three causes by string match. A cancel issued **before** the call dispatches is parked and applied the moment the call registers (still returns `false` — no live call was flipped yet). `createAgent({ toolKill: { defaultMessage } })` sets the reason stamped when `cancelTool(callId)` is called without one.
|
|
474
483
|
|
|
475
484
|
Agents also implement `Symbol.asyncDispose` (aliased to `destroy`) so they work with TC39 explicit-resource-management:
|
|
476
485
|
|
|
@@ -482,68 +491,18 @@ await agent.run({ prompt: 'go' })
|
|
|
482
491
|
|
|
483
492
|
Requires Node 20+ (or Bun). Idempotent, same semantics as calling `destroy()` directly.
|
|
484
493
|
|
|
485
|
-
## Background
|
|
494
|
+
## Background Tasks
|
|
486
495
|
|
|
487
|
-
|
|
496
|
+
`shell({ run_in_background: true })` returns immediately with a task handle instead of blocking the model's turn; `shell_kill` terminates a task; `wait_task` blocks until one exits. Enable by wiring a task log directory:
|
|
488
497
|
|
|
489
498
|
```ts
|
|
490
499
|
import { createAgent, resolveTasksDir } from 'zidane'
|
|
491
500
|
|
|
492
501
|
const tasksDir = resolveTasksDir({ userDir: '~/.zidane', sessionId: session.id })
|
|
493
|
-
const agent = createAgent({
|
|
494
|
-
provider,
|
|
495
|
-
session,
|
|
496
|
-
behavior: { tasksDir }, // host opt-in for background mode
|
|
497
|
-
// ... rest of options
|
|
498
|
-
})
|
|
502
|
+
const agent = createAgent({ provider, session, behavior: { tasksDir } })
|
|
499
503
|
```
|
|
500
504
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
- `behavior.tasksDir` is **unset** — the host hasn't wired the log dir, so the model would only ever get an error if it tried. The auto-disable means the model never sees the flag in the first place; no wasted turns.
|
|
504
|
-
- `behavior.disableBackgroundTasks: true` is set — explicit opt-out for hosts who have `tasksDir` for some other reason (legacy fixture, integration test) but don't want the model spawning background work this run.
|
|
505
|
-
- `behavior.disableBackgroundTasks: 'non-durable'` is set and the execution context's `capabilities.detachedTasks` is below `'durable'` — the capability-aware form durable-execution hosts use (`restateBehavior()` defaults to it), so remote contexts whose tasks survive worker crashes keep background mode while host-local processes don't.
|
|
506
|
-
- the execution context's `capabilities.detachedTasks` is `'none'` — the context can't background at all, so the schema would advertise a dead flag.
|
|
507
|
-
|
|
508
|
-
The path is per-session by convention (`<userDir>/sessions/<sessionId>/tasks/`) so a session delete cleans up log files alongside other session state.
|
|
509
|
-
|
|
510
|
-
The auto-rewrite only fires for the identity-equal framework `shell` tool — hosts who register a custom shell-named tool keep ownership of their spec. For explicit control, hosts can import `createShellTool({ allowBackground })` from `zidane` and register the tailored variant directly.
|
|
511
|
-
|
|
512
|
-
**Task lifecycle.**
|
|
513
|
-
|
|
514
|
-
1. Model calls `shell({ command: 'npm run build', run_in_background: true })`.
|
|
515
|
-
2. The shell tool spawns a process group via `ExecutionContext.execBackground`, opens `<tasksDir>/<task-id>.<context-timestamp>.log` (append mode), pipes stdout+stderr into it, and returns immediately with the task id + pid + log path. `background:start` fires. The `<context-timestamp>` segment (`YYYYMMDD-HHMMSS-mmm`, UTC, shared across every task in the same `ExecutionContext` instance) keeps two contexts on the same session from ever resolving to the same log file — the model-facing task id (`bash_1`) stays short.
|
|
516
|
-
3. The process exits naturally OR via `shell_kill({ task_id })`. `background:exit` fires exactly once, with `status: 'exited' | 'killed'`.
|
|
517
|
-
4. The agent's internal `background:exit` listener queues a `TaskExitInfo` (keyed by `taskId` in a `Map`).
|
|
518
|
-
5. On the NEXT `agent.run()`, the agent **reconciles**: the push-fed queue is merged with a `listBackground` sweep for terminated tasks the model hasn't been told about (the pull side is what makes remote/durable contexts work — `onExit` is optional on the contract, since a remote context can't push from a timer into the host). The merged list passes through the mutable `background:reconcile` hook (durable hosts journal it there), then each entry becomes a leading `text` content block on the seeded user turn carrying `<task-notification>…</task-notification>` XML — the model reads task completions inline with its prompt, no polling needed. A per-agent `notified` latch keeps the pull sweep from re-notifying the same terminated task on later runs.
|
|
519
|
-
|
|
520
|
-
**Suppression.** The agent's `tool:after` listener watches for `shell_kill` (deletes from the queue by `task_id`), `wait_task` (deletes by `task_id` when the wait returned exit info — timeouts don't suppress, the eventual exit is still news), and `read_file` / `read` (deletes by matching the file's `path` against any pending entry's `outputPath`). This keeps the model from receiving a redundant `<task-notification>` for an exit it already learned about through other means.
|
|
521
|
-
|
|
522
|
-
**Guardrails.** Two opt-in behavior knobs protect long unattended jobs:
|
|
523
|
-
|
|
524
|
-
- `behavior.backgroundOutputCap` (bytes) — past the cap the task keeps running but further output is dropped; a `<output-truncated bytes-dropped="N"/>` marker is appended to the log when the task settles.
|
|
525
|
-
- `behavior.backgroundStallWatchdogMs` — when a running task produces no output for the window, a `background:stall` hook fires and a one-shot `<task-stall>` notification is queued for the model's next turn (re-arms after fresh output). The process is NOT killed — the model decides, typically by reading the log tail and `shell_kill`ing a task stuck at an interactive prompt.
|
|
526
|
-
|
|
527
|
-
**Output reading.** The model reads task output via the normal `read_file` tool — no special API. The file lives at the absolute path returned in the spawn result; offset + limit work for tail-watching. The file stays around until the session is deleted.
|
|
528
|
-
|
|
529
|
-
**Shutdown.** Two paths:
|
|
530
|
-
|
|
531
|
-
1. **Clean shutdown (`agent.destroy()`)** — SIGTERMs every still-running task (entire process groups, so no orphans), awaits each log stream's flush, then tears down the execution handle. Notification queue cleared. The TUI invokes this via its session-teardown flow.
|
|
532
|
-
2. **Process-exit safety net (Ctrl+C, crash)** — `ProcessContext` lazy-registers a `process.on('exit')` handler on the first spawn that synchronously SIGTERMs every still-running task's process group. Output streams may not flush their last buffer (Node's `'exit'` listeners can't await), but the children DO die — no orphan `npm run dev` outliving a Ctrl+C'd TUI. The handler is deregistered in `destroy()` so repeated session-swap cycles don't pile listeners onto the global event emitter.
|
|
533
|
-
|
|
534
|
-
**Replay.** Persisted `<task-notification>` text blocks survive in the session's turn history; `eventsFromTurns` re-synthesizes them as `'task-notification'` `StreamEvent`s on load so the TUI's banner renderer paints them the same way live + replayed.
|
|
535
|
-
|
|
536
|
-
**Subagents.** A `spawn`'d subagent can call `shell({ run_in_background: true })` — but it has no "next user-turn" of its own to receive the `<task-notification>` on (a subagent's `agent.run()` ends as soon as the model returns `done: true`). The plumbing handles this transparently:
|
|
537
|
-
|
|
538
|
-
1. Subagent starts the task → registered under the subagent's `ExecutionHandle`.
|
|
539
|
-
2. Subagent's `agent.run` finishes (the model SHOULD return a brief summary that includes the `task_id` and end its turn — the shell tool's success-result is depth-aware and tells the model exactly this).
|
|
540
|
-
3. `spawn.ts`'s `finally` calls `ExecutionContext.reassignBackgroundTasks(childHandle, parentHandle, newOnExit)` BEFORE `agent.destroy()`. The task's `handleId` is rewritten to the parent's, and the task's natural-exit callback is rewired from the (about-to-be-destroyed) subagent's hook bus to the parent's. `background:reassign` fires on the parent's bus.
|
|
541
|
-
4. Subagent's `destroy()` walks its registry and finds no surviving tasks (they've all been promoted), so it kills nothing.
|
|
542
|
-
5. When the task eventually exits, `background:exit` fires on the PARENT's bus → enqueued → drained on the parent's next user-turn.
|
|
543
|
-
|
|
544
|
-
For nested subagents (grandchild spawns a background task while running inside a child that itself was spawned by the parent), the reassign chain runs at each spawn finally: grandchild → child at grandchild-finish, child → parent at child-finish. The task ends up owned by the top-level parent. `child:background:reassign` bubbles through so observability hooks see the full chain.
|
|
545
|
-
|
|
546
|
-
**Context support.** Background mode is OPTIONAL on `ExecutionContext` — the methods (`execBackground`, `killBackground`, `listBackground`, `waitBackground`) are declared as `?:` so existing contexts compile unchanged. The `ProcessContext` (in-process default) implements all of them; remote sandboxes that don't support detached spawns can leave them undefined and the shell tool surfaces a clean error. Contexts declare their task-lifetime guarantee via `capabilities.detachedTasks` (`'none' | 'process-lifetime' | 'durable'`; `resolveDetachedTasksCapability` infers `'process-lifetime'` for legacy contexts that implement `execBackground` without the field). The same is true for `agent.destroy()`'s teardown — it walks the registry only if the context exposes one.
|
|
505
|
+
Without `behavior.tasksDir` (or with `behavior.disableBackgroundTasks`, or a context whose `capabilities.detachedTasks` doesn't qualify), the `run_in_background` field is dropped from `shell`'s schema entirely — the model never sees a flag it can't use. Task completions are delivered as `<task-notification>` XML on the model's next turn, no polling. Full lifecycle (guardrails, shutdown, subagent task reassignment, replay, per-context support): `docs/RUN_IN_BACKGROUND.md`.
|
|
547
506
|
|
|
548
507
|
## Sessions
|
|
549
508
|
|
|
@@ -566,31 +525,21 @@ const loaded = await loadSession(store, session.id)
|
|
|
566
525
|
| `createRemoteStore({ url })` | `zidane` | Server-side | Delegated |
|
|
567
526
|
| `createFileMapStore(adapter)` | `zidane` | `meta.json` + `turns.jsonl` | Single session, auto-creates |
|
|
568
527
|
|
|
569
|
-
Turns persist incrementally via `session:turns` — crashes leave partial-but-valid history. Call `session.save()` only when your store batches writes. `session.isEmpty` is a "first prompt" signal.
|
|
570
|
-
|
|
571
|
-
`createFileMapStore(adapter)` wraps any `{ get, save, delete }` backend. Format is `turns.jsonl` (one `SessionTurn` per line) + `meta.json` — JSONL is debuggable and survives partial corruption (parse up to the first bad line).
|
|
528
|
+
Turns persist incrementally via `session:turns` — crashes leave partial-but-valid history. Call `session.save()` only when your store batches writes. `session.isEmpty` is a "first prompt" signal. `createFileMapStore(adapter)` wraps any `{ get, save, delete }` backend; JSONL is debuggable and survives partial corruption (parse up to the first bad line).
|
|
572
529
|
|
|
573
530
|
### SQLite schema migrations (v3 → v4)
|
|
574
531
|
|
|
575
|
-
`createSqliteStore` migrates older DBs in place on first open
|
|
576
|
-
|
|
577
|
-
**Rollback procedure.** If a session loads incorrectly after migration:
|
|
578
|
-
|
|
579
|
-
1. Quit every zidane process holding the DB.
|
|
580
|
-
2. Restore the snapshot and clear the now-stale WAL sidecars:
|
|
581
|
-
|
|
582
|
-
```sh
|
|
583
|
-
DB=~/.zidane/sessions.db # adjust to your path
|
|
584
|
-
cp "$DB.zidane-pre-v4.bak" "$DB"
|
|
585
|
-
rm -f "$DB-wal" "$DB-shm"
|
|
586
|
-
```
|
|
532
|
+
`createSqliteStore` migrates older DBs in place on first open, writing a point-in-time snapshot to `<db>.zidane-pre-v4.bak` before any destructive change (created once, never overwritten on later opens). A one-line stderr notice on first migration reports the path.
|
|
587
533
|
|
|
588
|
-
|
|
589
|
-
4. Restart zidane.
|
|
534
|
+
**Rollback:** quit every zidane process holding the DB, then:
|
|
590
535
|
|
|
591
|
-
|
|
536
|
+
```sh
|
|
537
|
+
DB=~/.zidane/sessions.db # adjust to your path
|
|
538
|
+
cp "$DB.zidane-pre-v4.bak" "$DB"
|
|
539
|
+
rm -f "$DB-wal" "$DB-shm"
|
|
540
|
+
```
|
|
592
541
|
|
|
593
|
-
|
|
542
|
+
Pin the previous zidane version (reads v3 directly) or upgrade to a fixed release — the migration is idempotent, so re-running it skips already-correct rows via a `turns` row-count check. For per-session recovery without a full rollback, the `.bak` is a valid v3 SQLite file: open it with `sqlite3` (`SELECT data FROM sessions WHERE id = ?`) and hand-import the JSON blob. Delete the `.bak` once every session loads correctly.
|
|
594
543
|
|
|
595
544
|
## Sub-agent Spawning
|
|
596
545
|
|
|
@@ -618,17 +567,15 @@ Three **independent** flags control what crosses the parent/child boundary:
|
|
|
618
567
|
|
|
619
568
|
| Flag | Default | What it shares |
|
|
620
569
|
|---|---|---|
|
|
621
|
-
| `persist` | `false` | The parent's `Session`. Child turns get appended to the same `session.turns` stream with the child's `runId`; the child's `SessionRun` carries `parentRunId` so the tree is reconstructible. As a side
|
|
570
|
+
| `persist` | `false` | The parent's `Session`. Child turns get appended to the same `session.turns` stream with the child's `runId`; the child's `SessionRun` carries `parentRunId` so the tree is reconstructible. As a side effect the read-state map is also shared. |
|
|
622
571
|
| `shareReadState` | `false` | Only the read-state map. The child gets `AgentOptions.readState` populated with the parent's resolved map but **no session** — `read_file` populations land in the parent's tracking, but child turns stay in-memory. |
|
|
623
|
-
| `shareHandle` | `false` | The parent's `ExecutionHandle`. The child skips its own `execution.spawn()` and never destroys the handle (host owns the lifecycle)
|
|
624
|
-
|
|
625
|
-
Always inherited (not flags): the parent's `AgentClock`, so a durable host's journaled time/UUID source stamps child metadata too, and `AgentBehavior`, which carries the durable tool-batch scheduler.
|
|
572
|
+
| `shareHandle` | `false` | The parent's `ExecutionHandle`. The child skips its own `execution.spawn()` and never destroys the handle (host owns the lifecycle). Matters most for remote contexts (sandbox / container) — under a durable runtime it's an unjournaled side effect that orphans a sandbox on every replayed attempt. See `docs/RESTATE.md` § Subagents. |
|
|
626
573
|
|
|
627
|
-
Use `shareReadState: true`
|
|
574
|
+
Always inherited (not flags): the parent's `AgentClock` and `AgentBehavior` (carries the durable tool-batch scheduler). Use `shareReadState: true` without `persist` when the parent's `requireReadBeforeEdit` gate should honor reads the child performed, without polluting the parent's session with child turns.
|
|
628
575
|
|
|
629
576
|
### Subagent type registry
|
|
630
577
|
|
|
631
|
-
When configured, the spawn tool's input schema gains a `subagent_type` enum the model uses to pick a per-type preset. Mirrors the Claude Code SDK surface
|
|
578
|
+
When configured, the spawn tool's input schema gains a `subagent_type` enum the model uses to pick a per-type preset. Mirrors the Claude Code SDK surface (`Explore` / `Plan` / `Verification` / `general-purpose`):
|
|
632
579
|
|
|
633
580
|
```ts
|
|
634
581
|
createSpawnTool({
|
|
@@ -640,7 +587,7 @@ createSpawnTool({
|
|
|
640
587
|
})
|
|
641
588
|
```
|
|
642
589
|
|
|
643
|
-
Per-type overlay fields: `system` (
|
|
590
|
+
Per-type overlay fields: `system` (per-call `input.system` still wins), `model` (per-call `input.model` wins), `tools: string[]` (canonical-name allow-list, subset of the parent's tools), `readonly: true` (shorthand for `['read_file', 'grep', 'glob', 'list_files']`), `description` (rendered for model self-selection). Unknown type names degrade to `'general-purpose'` (no overlay, no error). Empty / unset registry drops the schema field entirely.
|
|
644
591
|
|
|
645
592
|
## Interaction Tool
|
|
646
593
|
|
|
@@ -657,6 +604,8 @@ const askUser = createInteractionTool({
|
|
|
657
604
|
// tools: { ...basicTools, ask_user: askUser }
|
|
658
605
|
```
|
|
659
606
|
|
|
607
|
+
See `docs/INTERACTIONS.md` for the `present_plan` / `ask_user` protocol built on this.
|
|
608
|
+
|
|
660
609
|
## MCP Servers
|
|
661
610
|
|
|
662
611
|
MCP connects lazily on first run, bootstrapped in parallel — the first `run()` waits on the slowest. Tools namespaced `mcp_{server}_{tool}`. Partial-failure tolerant — only all-fail throws.
|
|
@@ -672,7 +621,7 @@ const agent = createAgent({
|
|
|
672
621
|
})
|
|
673
622
|
```
|
|
674
623
|
|
|
675
|
-
Per-server knobs: `bootstrapTimeout` (default **10 s**, caps `connect + listTools`
|
|
624
|
+
Per-server knobs: `bootstrapTimeout` (default **10 s**, caps `connect + listTools`), `toolTimeout` (default **30 s**, caps `callTool`), `closeTimeout` (default **5 s**, caps shutdown inside `McpConnection.close()`; on timeout a stdio child is SIGKILLed), `strictEnv` (stdio only; pass `env` verbatim, off by default so `env: {}` doesn't strip `PATH`).
|
|
676
625
|
|
|
677
626
|
### Hiding bootstrap latency
|
|
678
627
|
|
|
@@ -681,14 +630,11 @@ await Promise.all([agent.warmup(), authenticate(), loadConfig()]) // pre-warm
|
|
|
681
630
|
const agent = createAgent({ provider, mcpServers, eager: true }) // or kick off automatically
|
|
682
631
|
```
|
|
683
632
|
|
|
684
|
-
`warmup()` is idempotent + concurrency-safe — callers share one promise, rejections clear for retry, post-`destroy()` is a no-op.
|
|
685
|
-
|
|
686
|
-
Two hooks fire per bootstrap regardless of outcome — attribute cold-start latency per server:
|
|
633
|
+
`warmup()` is idempotent + concurrency-safe — callers share one promise, rejections clear for retry, post-`destroy()` is a no-op. `mcp:bootstrap:end` fires per server regardless of outcome — attribute cold-start latency:
|
|
687
634
|
|
|
688
635
|
```ts
|
|
689
636
|
agent.hooks.hook('mcp:bootstrap:end', (ctx) => {
|
|
690
|
-
// ctx.name, ctx.transport, ctx.durationMs, ctx.ok
|
|
691
|
-
// ok ? ctx.toolCount : ctx.error
|
|
637
|
+
// ctx.name, ctx.transport, ctx.durationMs, ctx.ok ? ctx.toolCount : ctx.error
|
|
692
638
|
})
|
|
693
639
|
```
|
|
694
640
|
|
|
@@ -696,45 +642,36 @@ agent.hooks.hook('mcp:bootstrap:end', (ctx) => {
|
|
|
696
642
|
|
|
697
643
|
### Stdio env handling
|
|
698
644
|
|
|
699
|
-
For `transport: 'stdio'`, `env` **merges** on top of the MCP SDK's inherited-env whitelist (POSIX: `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, `USER`; Win32: `APPDATA`, `PATH`, …) — `env: { MY_TOKEN: '…' }` no longer strips `PATH` and trips `spawn ENOENT`. Use `strictEnv: true` only for fully-isolated child envs
|
|
645
|
+
For `transport: 'stdio'`, `env` **merges** on top of the MCP SDK's inherited-env whitelist (POSIX: `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, `USER`; Win32: `APPDATA`, `PATH`, …) — `env: { MY_TOKEN: '…' }` no longer strips `PATH` and trips `spawn ENOENT`. Use `strictEnv: true` only for fully-isolated child envs.
|
|
700
646
|
|
|
701
647
|
### Where a stdio server runs (`scope`)
|
|
702
648
|
|
|
703
|
-
`scope: 'host'` (default) spawns the server as a child of the agent process. `scope: 'context'` spawns it **inside** the agent's execution context instead, via `ExecutionContext.execStream
|
|
649
|
+
`scope: 'host'` (default) spawns the server as a child of the agent process. `scope: 'context'` spawns it **inside** the agent's execution context instead, via `ExecutionContext.execStream` — required when a sandboxed context (Docker / E2B / Daytona) must share a filesystem with an MCP tool that writes files the agent's own tools then read:
|
|
704
650
|
|
|
705
651
|
```ts
|
|
706
652
|
mcpServers: [
|
|
707
|
-
// Touches the workspace → must share the agent's filesystem.
|
|
708
653
|
{ name: 'fs', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'], scope: 'context' },
|
|
709
|
-
|
|
710
|
-
{ name: 'github', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] },
|
|
654
|
+
{ name: 'github', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }, // network-only → leave on host
|
|
711
655
|
]
|
|
712
656
|
```
|
|
713
657
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
Two operational requirements:
|
|
717
|
-
|
|
718
|
-
- **The context image must contain the server's runtime.** A missing `node` / `uvx` / `bunx` surfaces as a bootstrap error for that server (the rest of the agent keeps working). Verified available out of the box: E2B ships `node` + `npx` + `python3`, Daytona ships those plus `bun` and `uvx`.
|
|
719
|
-
- **The `docker` context additionally needs the `docker` CLI on `PATH`** for `scope: 'context'` servers specifically — unlike every other Docker operation, which only needs the daemon socket. The CLI is used because dockerode's hijacked exec (the only HTTP-API route to a writable stdin) hangs under Bun. `DOCKER_HOST` is still honoured, so remote daemons work.
|
|
658
|
+
Requires a context advertising `capabilities.stream` (`process`, `docker`, `e2b`, `daytona`; not `createMockContext`). Ignored for `sse` / `streamable-http`. Filesystem coherence, **not** isolation — remote MCP servers stay unreachable. The context image must contain the server's runtime (missing `node`/`uvx`/`bunx` → bootstrap error for that server only); `docker` additionally needs the `docker` CLI on `PATH` for `scope: 'context'` (dockerode's hijacked exec hangs under Bun; `DOCKER_HOST` still honored).
|
|
720
659
|
|
|
721
660
|
### Per-server tool filtering
|
|
722
661
|
|
|
723
|
-
`enabledTools` / `disabledTools` / `toolFilter` apply at config time (before registration);
|
|
662
|
+
`enabledTools` / `disabledTools` / `toolFilter` apply at config time (before registration); `mcp:tools:filter` is the runtime escape hatch when host-side policy depends on user/environment. Config filters compose: allow-list, then deny-list, then predicate, then hook. `cachedTools` skips the `tools/list` round-trip when the host can supply schemas itself; `lazyConnect: true` (requires `cachedTools`, incompatible with `auth: 'oauth'`) defers `client.connect()` to the first `tools/call`.
|
|
724
663
|
|
|
725
664
|
### Server instructions
|
|
726
665
|
|
|
727
|
-
Servers can return an `instructions` payload on the MCP `initialize` handshake
|
|
728
|
-
|
|
729
|
-
Opt out with `behavior.surfaceMcpInstructions: false` (default `true`). Only eager-connect servers contribute — `lazyConnect: true` servers would have to force a connection to fetch the field, which defeats the deferral.
|
|
666
|
+
Servers can return an `instructions` payload on the MCP `initialize` handshake. Zidane captures it via the SDK's `getInstructions()` and renders it into the system prompt as a `# MCP Server Instructions` section, grouped by server name in `mcpServers` config order — lives on the static (cached) half of the prompt. Opt out with `behavior.surfaceMcpInstructions: false` (default `true`). Only eager-connect servers contribute (`lazyConnect: true` would have to force a connection to fetch the field).
|
|
730
667
|
|
|
731
668
|
### Tool naming
|
|
732
669
|
|
|
733
|
-
Canonical wire form is `mcp_<server>_<tool>` (single underscore). Models trained on the Claude Code SDK may emit the double-underscore form (`mcp__<server>__<tool>`); zidane auto-
|
|
670
|
+
Canonical wire form is `mcp_<server>_<tool>` (single underscore). Models trained on the Claude Code SDK may emit the double-underscore form (`mcp__<server>__<tool>`); zidane auto-recognizes that as an inbound alias for every MCP tool. Inbound-only — the wire still advertises the single-underscore canonical.
|
|
734
671
|
|
|
735
|
-
## Progressive
|
|
672
|
+
## Progressive Tool Disclosure
|
|
736
673
|
|
|
737
|
-
`behavior.toolDisclosure: 'lazy'` swaps MCP tools for a name-only catalog and auto-injects a `tool_search` native tool. Native + skill tools stay eager.
|
|
674
|
+
`behavior.toolDisclosure: 'lazy'` swaps MCP tools for a name-only catalog and auto-injects a `tool_search` native tool. Native + skill tools stay eager. Flow diagram: `docs/ARCHITECTURE.md` § Progressive Tool Disclosure.
|
|
738
675
|
|
|
739
676
|
```ts
|
|
740
677
|
createAgent({
|
|
@@ -748,11 +685,9 @@ createAgent({
|
|
|
748
685
|
})
|
|
749
686
|
```
|
|
750
687
|
|
|
751
|
-
The system prompt gains a `<searchable_tools>` block. `tool_search({ query, names, server, limit })` returns matching schemas and unlocks them for the rest of the run. A `tool:gate` middleware refuses un-surfaced lazy tools
|
|
688
|
+
The system prompt gains a `<searchable_tools>` block. `tool_search({ query, names, server, limit })` returns matching schemas and unlocks them for the rest of the run. A `tool:gate` middleware refuses un-surfaced lazy tools. Catalog + search results show the **wire** name; the unlock set keys on canonical so dispatch / `session.turns` stay alias-stable.
|
|
752
689
|
|
|
753
|
-
`behavior.toolSearch` knobs (no-op in eager mode): `tool: false` opts out of auto-injection
|
|
754
|
-
|
|
755
|
-
Cost: one cache miss per discovery wave (tool list grows); subsequent turns hit cache normally.
|
|
690
|
+
`behavior.toolSearch` knobs (no-op in eager mode): `tool: false` opts out of auto-injection; `limit: 20` caps default results. Per-server `disclosure: 'eager' | 'lazy'` overrides the agent default; run-level overrides agent-level. Cost: one cache miss per discovery wave; subsequent turns hit cache normally.
|
|
756
691
|
|
|
757
692
|
## Skills
|
|
758
693
|
|
|
@@ -784,7 +719,7 @@ metadata:
|
|
|
784
719
|
Instructions here…
|
|
785
720
|
```
|
|
786
721
|
|
|
787
|
-
### Loading
|
|
722
|
+
### Loading and enabling
|
|
788
723
|
|
|
789
724
|
```ts
|
|
790
725
|
createAgent({
|
|
@@ -811,33 +746,27 @@ When the catalog is non-empty and `tool !== false`, three tools auto-inject:
|
|
|
811
746
|
|
|
812
747
|
| Tool | Purpose |
|
|
813
748
|
|---|---|
|
|
814
|
-
| `skills_use` | Activate or deactivate a skill by name. `mode: 'activate'` (default, spec-mandated) loads the skill body
|
|
749
|
+
| `skills_use` | Activate or deactivate a skill by name. `mode: 'activate'` (default, spec-mandated) loads the skill body wrapped in `<skill_content spec_version="0.1">`; `mode: 'deactivate'` (zidane extension) releases an active skill so its `allowed-tools` restrictions stop applying — fires `skills:deactivate` with `reason: 'model'`. Idempotent both ways. |
|
|
815
750
|
| `skills_read` | Read a bundled resource (`references/`, `assets/`) of an active skill. |
|
|
816
751
|
| `skills_run_script` | Run `scripts/*.sh` / `*.py` with args. Returns `{ exitCode, stdout, stderr }`. |
|
|
817
752
|
|
|
818
753
|
Shell interpolation (`` !`cmd` ``) runs **per activation** — `` !`gh pr diff` `` reflects the current PR each time.
|
|
819
754
|
|
|
820
|
-
Programmatic activation:
|
|
821
|
-
|
|
822
755
|
```ts
|
|
823
756
|
await agent.activateSkill('pdf-processing') // skills:activate, via: 'explicit'
|
|
824
757
|
agent.activeSkills // readonly ActiveSkill[]
|
|
825
758
|
await agent.deactivateSkill('pdf-processing') // skills:deactivate, reason: 'explicit'
|
|
826
759
|
```
|
|
827
760
|
|
|
828
|
-
`activateSkill` auto-resolves the catalog on its first call, so it's safe to invoke before `run()
|
|
761
|
+
`activateSkill` auto-resolves the catalog on its first call, so it's safe to invoke before `run()`. Pre-warm via `await agent.warmup()` or `eager: true` to pay the resolution cost up front. Throws `Cannot activate skill "<name>" — skills are disabled for this agent` only when `skills.enabled === false` / `enabled: []`.
|
|
829
762
|
|
|
830
|
-
### Enforcement
|
|
763
|
+
### Enforcement and resume
|
|
831
764
|
|
|
832
|
-
When any active skill declares `allowed-tools`, a `tool:gate` middleware blocks calls outside the union (the three skills tools are always implicitly allowed). Matching uses `displayName`, supports exact names (`Read`) and patterns (`Bash(git:*)`). Blocked calls throw `AgentToolNotAllowedError`, whose `.message`
|
|
765
|
+
When any active skill declares `allowed-tools`, a `tool:gate` middleware blocks calls outside the union (the three skills tools are always implicitly allowed). Matching uses `displayName`, supports exact names (`Read`) and patterns (`Bash(git:*)`). Blocked calls throw `AgentToolNotAllowedError`, whose `.message` points the model at `skills_use({ mode: 'deactivate', name })`. Full activation/deactivation state machine: `docs/ARCHITECTURE.md` § Skills Activation Lifecycle.
|
|
833
766
|
|
|
834
|
-
`allowed-tools` accepts three authoring shapes
|
|
767
|
+
`allowed-tools` accepts three authoring shapes, all normalized to `string[]`: spec-canonical space-separated string (`Read Bash(git:*)`), YAML block list, YAML flow list (`[Read, Bash(git:*)]`). The lenient parser (`src/skills/discovery.ts`) also tolerates `allowedTools` / `allowed_tools` alt-spellings, migrating to the canonical slot with a `frontmatter-key-alt-spelling` diagnostic.
|
|
835
768
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
On session resume, `skills_use` calls in the turn history rebuild the active set with **last-mode-wins per skill**: a trailing `mode: 'deactivate'` block correctly STAYS inactive on the next run (without this, the rehydrator would silently re-activate skills the model just released, defeating the recovery path). Activations fire with `via: 'resume'`; `skills:deactivate` does NOT fire during rehydration — the state started empty so there's nothing to deactivate.
|
|
839
|
-
|
|
840
|
-
Programmatic host-side activations (`agent.activateSkill(name)`) do NOT produce a `skills_use` block in history, so they don't survive the run-end deactivate-all pass automatically. Hosts that want persistence across runs (TUI slash-commands, for example) must re-activate before each `agent.run()`; the TUI does this via a pinned-skills set persisted in `session.metadata['zidane.activeSkills']` — see `docs/TUI.md`.
|
|
769
|
+
On resume, `skills_use` calls in turn history rebuild the active set with **last-mode-wins per skill**: a trailing `mode: 'deactivate'` stays inactive. Activations fire with `via: 'resume'`; `skills:deactivate` does not fire during rehydration. Programmatic `agent.activateSkill(name)` calls leave no history block, so they don't survive run-end deactivation — hosts wanting persistence (TUI slash-commands) must re-activate before each `run()` (the TUI uses `session.metadata['zidane.activeSkills']`, see `docs/TUI.md`).
|
|
841
770
|
|
|
842
771
|
### Installing skills (GitHub / npm / skills.sh)
|
|
843
772
|
|
|
@@ -849,18 +778,16 @@ await installSkillFromSpec({ source: 'https://skills.sh/anthropics/skills/fronte
|
|
|
849
778
|
await installSkillFromSpec({ source: 'owner/repo#main:skills/foo', scope: 'user', preferSkillsCli: false })
|
|
850
779
|
```
|
|
851
780
|
|
|
852
|
-
Shares the extensions installer's source layer: `owner/repo[#ref][:subdir]`, GitHub URLs
|
|
781
|
+
Shares the extensions installer's source layer: `owner/repo[#ref][:subdir]`, GitHub URLs, `pkg[@version]`, `@scope/pkg`, plus `skills.sh/<owner>/<repo>[/<skill>]` catalog URLs. Engine: the **skills.sh CLI** when on PATH (installs to `.agents/skills/`), else native fetch into `~/.zidane/skills/<name>` or `<gitRoot>/.zidane/skills/<name>`; `preferSkillsCli: false` forces native.
|
|
853
782
|
|
|
854
|
-
|
|
783
|
+
Validation **never executes code** — candidates are parsed with the same lenient `SKILL.md` parser as discovery, via a bounded recursive walk (any dir with a `SKILL.md`, ≤4 levels deep). Multi-skill repos throw `multiple-candidates`; `skill` selects by name or path on collision. Same hardening as extensions: staged fetch, symlink containment, opt-in overwrite with backup/rollback, `.zidane-install.json` provenance sidecar. Errors are typed `SkillInstallError`s with `.code`.
|
|
855
784
|
|
|
856
|
-
In the TUI: **Settings → Skills → `ctrl+g`** (source → scope flow, same gesture as
|
|
785
|
+
In the TUI: **Settings → Skills → `ctrl+g`** (source → scope flow, same gesture as Extensions).
|
|
857
786
|
|
|
858
787
|
### Lenient load, strict authoring
|
|
859
788
|
|
|
860
789
|
Third-party skills with minor deviations load anyway via `parseSkillFile` (warnings in `SkillConfig.diagnostics`). `defineSkill` / `writeSkillToDisk` run `validateSkillForWrite` — spec-strict.
|
|
861
790
|
|
|
862
|
-
Hooks: `skills:resolve` (`{ skills }`), `skills:catalog` (`{ catalog, skills }`), `skills:activate` (`{ skill, via: 'model'|'explicit'|'resume' }`), `skills:deactivate` (`{ skill, reason: 'run-end'|'explicit'|'reset'|'model' }`).
|
|
863
|
-
|
|
864
791
|
## Structured Output
|
|
865
792
|
|
|
866
793
|
Force the final response to a JSON Schema. A synthetic `__output__` tool is injected with `toolChoice` on the **final turn only** (after the model signals done); mid-run tool calls are unaffected. Result lands on `stats.output` and fires the `output` hook.
|
|
@@ -906,6 +833,8 @@ createDockerContext({ image: 'node:22', cwd: '/workspace' })
|
|
|
906
833
|
createSandboxContext(myProvider) // implement SandboxProvider (E2B, Rivet, …)
|
|
907
834
|
```
|
|
908
835
|
|
|
836
|
+
Capability model, remote/sandbox contexts, and the `ExecutionHandle` lifecycle: `docs/EXECUTION_CONTEXT.md`.
|
|
837
|
+
|
|
909
838
|
## Key Types
|
|
910
839
|
|
|
911
840
|
| Type | Description |
|
|
@@ -913,7 +842,7 @@ createSandboxContext(myProvider) // implement SandboxProvider (E2B, Rivet, …)
|
|
|
913
842
|
| `AgentOptions` | `createAgent()` config (+ `eager?` to pre-warm MCP). |
|
|
914
843
|
| `AgentRunOptions` | `agent.run()` config. |
|
|
915
844
|
| `AgentBehavior` | All `behavior.*` fields. |
|
|
916
|
-
| `AgentStats` | Run result. `totalIn
|
|
845
|
+
| `AgentStats` | Run result. `totalIn`/`totalOut`/`totalCacheRead`/`totalCacheCreation`/`cost` are cumulative (parent + children); `turns`/`turnUsage` parent-only. Plus `elapsed`, `output`, `children?`, `timeTillFirstTokenMs`. |
|
|
917
846
|
| `Agent` | Instance (run, abort, steer, hooks, …). |
|
|
918
847
|
| `Preset` | `Omit<Partial<AgentOptions>, 'provider' \| 'execution' \| 'session' \| 'mcpConnector'>`. |
|
|
919
848
|
| `ToolDef` | `{ spec, execute }`. `execute` returns `string \| ToolResultContent[]`. |
|
|
@@ -931,20 +860,19 @@ createSandboxContext(myProvider) // implement SandboxProvider (E2B, Rivet, …)
|
|
|
931
860
|
| `SkillConfig` / `SkillsConfig` | Parsed skill definition / config (`scan, write, exclude, enabled`). |
|
|
932
861
|
| `InteractionToolOptions` | `createInteractionTool` config (`schema, onRequest`). |
|
|
933
862
|
| `PromptPart` | `{ type: 'text' \| 'image' \| 'document', ... }`. |
|
|
934
|
-
| `
|
|
935
|
-
| `
|
|
936
|
-
| `
|
|
937
|
-
| `
|
|
938
|
-
| `
|
|
863
|
+
| `AgentHookMap` | `{ [event]: handler \| handler[] }` — agent-lifetime hooks on `AgentOptions.hooks`. |
|
|
864
|
+
| `RunHookMap` | Same shape, per-run scope (`agent.run({ hooks })`); handlers auto-detach. |
|
|
865
|
+
| `AgentBehavior.maxConcurrentTools` | Cap on in-flight tools per turn (default `10`, clamped at `1`). See [Per-tool concurrency](#per-tool-concurrency). |
|
|
866
|
+
| `ToolDef.isConcurrencySafe` | `boolean \| ((input) => boolean)`. Defaults to `false`. |
|
|
867
|
+
| `ToolHookContext` / `McpToolHookContext` / `SessionHookContext` / `StreamHookContext` / `SpawnHookContext` | Hook ctx base types. `ToolHookContext` / `McpToolHookContext` carry the `runId?`/`parentRunId?`/`parentCallId?`/`depth?` identity fields — see [Identity fields](#identity-fields-on-tool-hook-contexts). |
|
|
868
|
+
| `ReadStateMap` / `ReadStateEntry` | Per-file read-tracking map (`Map<canonicalAbsPath, { contentHash, offset, limit, maxBytes, lineNumbers?, mtimeMs }>`). Construct one manually for `AgentOptions.readState` to share across an agent tree; or get the session-bound one via `getReadState(session)` / `resolveReadStateMap(ctx)`. Canonical keys come from `readStateKey(cwd, path)`. |
|
|
939
869
|
| `ClassifiedError` | `{ kind, providerCode?, message? }` from `Provider.classifyError`. |
|
|
940
870
|
| `AgentContextExceededError` / `AgentProviderError` / `AgentAbortedError` / `AgentToolNotAllowedError` / `AgentBudgetExceededError` | Typed errors. `toTypedError(err)` wraps native errors. |
|
|
941
871
|
| `FileMapAdapter` | `{ get, save, delete }` — host-SDK seam for `createFileMapStore`. |
|
|
942
872
|
| `TracingHooksOptions` | `createTracingHooks` config (`startSpan`, `namespace?`). |
|
|
943
873
|
| `OpenAICompatParams` | `openaiCompat()` config (apiKey, baseURL, authHeader, extraHeaders, capabilities, cacheBreakpoints). |
|
|
944
874
|
| `ArceeParams` / `LocalParams` | OpenAI-compatible wrapper configs for Arcee Platform and self-hosted local endpoints. |
|
|
945
|
-
| `ValidationResult` | `{ valid, error?, coercedInput?, coercions?, droppedItems? }` — `validateToolArgs` output. `droppedItems`
|
|
946
|
-
| `AgentHookMap` | `{ [event]: handler \| handler[] }` — agent-lifetime hooks on `AgentOptions.hooks`. |
|
|
947
|
-
| `RunHookMap` | Same shape, per-run scope (`agent.run({ hooks })`); handlers auto-detach. |
|
|
875
|
+
| `ValidationResult` | `{ valid, error?, coercedInput?, coercions?, droppedItems? }` — `validateToolArgs` output. `droppedItems` keyed by field name, reports original indexes of items dropped during recursive array validation. |
|
|
948
876
|
| `toolOutputByteLength(content)` | Reproduces the loop's `outputBytes` formula. |
|
|
949
877
|
| `toolResultToText(result)` | Flatten `string \| ToolResultContent[]` to a plain string. |
|
|
950
878
|
| `tailTruncate(text, maxBytes, opts?)` | Tail-priority, UTF-8 byte-budgeted truncation (codepoint/surrogate-safe) — the shared discipline the `shell` tool uses. |
|
|
@@ -965,6 +893,7 @@ src/
|
|
|
965
893
|
system-prompt.ts System-prompt section helpers (the `zidane/prompt` entry)
|
|
966
894
|
mutation-validation.ts Host-side post-mutation validation hook helper
|
|
967
895
|
tracing.ts createTracingHooks
|
|
896
|
+
cache-telemetry.ts installCacheBreakLogger, CacheBreakInfo/CacheDriftInfo
|
|
968
897
|
stats.ts flattenTurns, statsByModel
|
|
969
898
|
types.ts Shared types
|
|
970
899
|
types-export.ts Public type surface for `zidane/types`
|
|
@@ -992,7 +921,11 @@ Narrow runtime subpaths for edge / Worker bundles — each re-exports root-publi
|
|
|
992
921
|
|
|
993
922
|
## Related
|
|
994
923
|
|
|
995
|
-
- `docs/ARCHITECTURE.md` — execution-flow diagrams, hook firing order, gate precedence.
|
|
924
|
+
- `docs/ARCHITECTURE.md` — execution-flow diagrams, hook firing order, gate precedence, concurrency scheduler, tool ergonomics defaults.
|
|
925
|
+
- `docs/RUN_IN_BACKGROUND.md` — background task lifecycle, wire format, subagent task reassignment.
|
|
926
|
+
- `docs/EXECUTION_CONTEXT.md` — `ExecutionContext` capability model, remote/sandbox contexts.
|
|
927
|
+
- `docs/ARTIFACTS.md` — session artifact model, `artifact:recorded` consumers.
|
|
996
928
|
- `docs/CHAT.md` — renderer-agnostic chat surface (auth, sessions, safe-mode, theme, edit-approval).
|
|
997
929
|
- `docs/TUI.md` — OpenTUI terminal shell built on top.
|
|
998
930
|
- `docs/INTERACTIONS.md` — `present_plan` / `ask_user` protocol.
|
|
931
|
+
- `docs/RESTATE.md` — durable execution (Restate) adapter, subagent journaling constraints.
|