zidane 6.4.3 → 6.5.0
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 +1 -0
- package/dist/{acp-Kr7LyExq.js → acp-B-AOovHz.js} +3 -3
- package/dist/{acp-Kr7LyExq.js.map → acp-B-AOovHz.js.map} +1 -1
- package/dist/acp-cli.js +3 -2
- package/dist/acp-cli.js.map +1 -1
- package/dist/acp.d.ts +1 -1
- package/dist/acp.js +1 -1
- package/dist/{agent-CEvzc1Ao.js → agent-SQ6rxfpT.js} +298 -110
- package/dist/agent-SQ6rxfpT.js.map +1 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +2 -2
- package/dist/chat/pure.d.ts +3 -3
- package/dist/chat.d.ts +13 -44
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +5 -4
- package/dist/chat.js.map +1 -1
- package/dist/{context-transport-DbzJctSl.js → context-transport-CDBatvQc.js} +3 -3
- package/dist/{context-transport-DbzJctSl.js.map → context-transport-CDBatvQc.js.map} +1 -1
- package/dist/contexts/daytona.d.ts +1 -1
- package/dist/contexts/e2b.d.ts +1 -1
- package/dist/eval.d.ts +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 +1 -1
- package/dist/{index-W_8010Lt.d.ts → index-CWBSrqI6.d.ts} +931 -3
- package/dist/index-CWBSrqI6.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -7
- package/dist/index.js.map +1 -1
- package/dist/{login-Blkiy-58.js → login-XZDS4K2o.js} +3 -3
- package/dist/{login-Blkiy-58.js.map → login-XZDS4K2o.js.map} +1 -1
- package/dist/{mcp-CDN-m5lp.js → mcp-D27rP47I.js} +87 -6
- package/dist/mcp-D27rP47I.js.map +1 -0
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +3 -2
- package/dist/origin-pinned-fetch-CqG8VZIq.js +159 -0
- package/dist/origin-pinned-fetch-CqG8VZIq.js.map +1 -0
- package/dist/output/stream-json.d.ts +1 -1
- package/dist/output/terminal.d.ts +1 -1
- package/dist/plugins-2PMTVcp-.js +1174 -0
- package/dist/plugins-2PMTVcp-.js.map +1 -0
- package/dist/plugins.d.ts +2 -0
- package/dist/plugins.js +2 -0
- package/dist/presets.d.ts +1 -1
- package/dist/presets.js +1 -1
- package/dist/providers/anthropic.d.ts +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/openrouter.d.ts +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/restate.d.ts +52 -2
- package/dist/restate.d.ts.map +1 -1
- package/dist/restate.js +156 -36
- package/dist/restate.js.map +1 -1
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/{resolve-CZJxds85.js → skills-Cp9vnBwd.js} +1430 -1006
- package/dist/skills-Cp9vnBwd.js.map +1 -0
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +1 -2
- package/dist/{stdio-DWQKYKad.js → stdio-O-m9WVkE.js} +2 -2
- package/dist/{stdio-DWQKYKad.js.map → stdio-O-m9WVkE.js.map} +1 -1
- package/dist/{stdio-loader-3uz37d_3.js → stdio-loader-BSGQmG4L.js} +2 -2
- package/dist/{stdio-loader-3uz37d_3.js.map → stdio-loader-BSGQmG4L.js.map} +1 -1
- package/dist/{tool-formatters-bhC2Xp8z.d.ts → tool-formatters-gFqQbzPY.d.ts} +2 -2
- package/dist/{tool-formatters-bhC2Xp8z.d.ts.map → tool-formatters-gFqQbzPY.d.ts.map} +1 -1
- package/dist/{read-state-Dt7UbVsd.js → tool-state-DTs-3ZHH.js} +218 -2
- package/dist/tool-state-DTs-3ZHH.js.map +1 -0
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +2 -2
- package/dist/{transcript-anchors-D7wVCZ2S.js → transcript-anchors-D4IxJifV.js} +42 -5
- package/dist/transcript-anchors-D4IxJifV.js.map +1 -0
- package/dist/{transcript-anchors-mcMLuXM1.d.ts → transcript-anchors-qCs3wZpu.d.ts} +98 -92
- package/dist/transcript-anchors-qCs3wZpu.d.ts.map +1 -0
- package/dist/tui.d.ts +38 -4
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +893 -148
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-B5vVcG0Z.d.ts → turn-operations-M9-jUyDd.d.ts} +3 -3
- package/dist/{turn-operations-B5vVcG0Z.d.ts.map → turn-operations-M9-jUyDd.d.ts.map} +1 -1
- package/dist/types-BIarq1qE.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/PLUGINS.md +340 -0
- package/docs/RESTATE.md +133 -3
- package/docs/SKILL.md +24 -8
- package/package.json +6 -1
- package/dist/agent-CEvzc1Ao.js.map +0 -1
- package/dist/index-W_8010Lt.d.ts.map +0 -1
- package/dist/mcp-CDN-m5lp.js.map +0 -1
- package/dist/read-state-Dt7UbVsd.js.map +0 -1
- package/dist/resolve-CZJxds85.js.map +0 -1
- package/dist/skills-Dd6XCi4s.js +0 -420
- package/dist/skills-Dd6XCi4s.js.map +0 -1
- package/dist/transcript-anchors-D7wVCZ2S.js.map +0 -1
- package/dist/transcript-anchors-mcMLuXM1.d.ts.map +0 -1
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { d as ClassifiedErrorKind, i as AgentContextExceededError, l as CONTEXT_EXCEEDED_MESSAGE_PATTERNS, n as AgentAbortedError, o as AgentProviderError, p as matchesContextExceeded, r as AgentBudgetExceededError, s as AgentToolNotAllowedError, u as ClassifiedError } from "./timeout-DEaqslLO.js";
|
|
2
|
-
import { $
|
|
2
|
+
import { $h as SkillsConfig, $m as SessionContentBlock, Bf as TurnResult, Bh as StagedToolState, Bm as ProgressNudgeMessage, C as SuccessfulMutationOutcome, Ch as TurnUsage, Cm as ChildRunStats, Dh as ToolContext, Eh as toolResultToText, Ff as StreamOptions, Gi as AgentHookMap, Gm as PromptTextPart, Hh as ToolStateOp, Hm as PromptDocumentPart, Hp as AnthropicParams, Jm as RepeatGuardToolMatcher, Ki as AgentHooks, Km as PromptVideoPart, Lf as ToolCall, Mf as Provider, Mh as ReadStateMap, Nf as ProviderCapabilities, Nm as McpServerConfig, Np as CerebrasParams, Ns as SpawnToolOptions, Oh as ToolDef, Om as DEFAULT_AGENT_CLOCK, Os as ChildAgent, Pf as StreamCallbacks, Pm as McpToolHookContext, Ps as SpawnToolState, Qm as RunHookMap, Rf as ToolResult, Rm as OAuthRefreshHookContext, Rr as InteractionToolOptions, S as MutationValidationResult, Sh as TurnFinishReason, Th as toolOutputByteLength, Uh as ToolStateStore, Um as PromptImagePart, Vh as ToolStateController, Vm as PromptAudioPart, Wh as ToolStateView, Wi as Agent, Wm as PromptPart, Xm as ResolveContentRefBlock, Yg as McpConnection, Yh as SkillConfig, Ym as ResolveContentRef, Zf as OpenRouterParams, Zh as SkillResource, Zm as RetryConfig, _ as statsByModel, _h as ToolResultImageContent, _m as AgentClock, ag as SessionRun, b as MutationValidationHooksOptions, bh as ToolResultTextContent, dh as ToolHookContext, eg as CreateSessionOptions, eh as SessionEndStatus, fh as ToolOutcome, gg as RemoteStoreOptions, gh as ToolResultDocumentRefContent, gm as AgentBehavior, h as flattenTurns, hh as ToolResultDocumentContent, ih as SpawnHookContext, jh as ReadStateEntry, kh as ToolMap, lh as ThinkingLevel, mh as ToolResultContent, ng as Session, nh as SessionMessage, og as SessionStore, p as ModelUsage, ph as ToolResultAudioContent, qi as AgentOptions, qm as RepeatGuardConfig, rg as SessionData, rh as SessionTurn, sh as StreamHookContext, t as Preset, th as SessionHookContext, v as MutationValidationBatch, vh as ToolResultImageRefContent, vm as AgentRunOptions, vr as ValidationResult, wh as toolOutputBudgetByteLength, x as MutationValidationMutation, xh as ToolResultVideoContent, xp as OpenAIParams, y as MutationValidationFailure, yh as ToolResultMetadata, ym as AgentStats, zf as ToolSpec, zm as ProgressNudgeKind } from "./index-CWBSrqI6.js";
|
|
3
3
|
import { _ as TaskHandle, a as ContextType, c as ExecutionContext, g as TaskExitInfo, h as TaskEntry, l as ExecutionHandle, n as ContextCapabilities, o as DetachedTasksCapability, p as SpawnConfig, r as ContextHardening, s as ExecResult, t as BackgroundTaskStatus, v as TaskStallInfo } from "./types-M2DauK2D.js";
|
|
4
4
|
import { t as SandboxProvider } from "./sandbox-DLY6uah7.js";
|
|
5
|
-
export { type Agent, AgentAbortedError, type AgentBehavior, AgentBudgetExceededError, type AgentClock, AgentContextExceededError, type AgentHookMap, type AgentHooks, type AgentOptions, AgentProviderError, type AgentRunOptions, type AgentStats, AgentToolNotAllowedError, type AnthropicParams, type BackgroundTaskStatus, CONTEXT_EXCEEDED_MESSAGE_PATTERNS, type CerebrasParams, type ChildAgent, type ChildRunStats, type ClassifiedError, type ClassifiedErrorKind, type ContextCapabilities, type ContextHardening, type ContextType, type CreateSessionOptions, DEFAULT_AGENT_CLOCK, type DetachedTasksCapability, type ExecResult, type ExecutionContext, type ExecutionHandle, type InteractionToolOptions, type McpConnection, type McpServerConfig, type McpToolHookContext, type ModelUsage, type MutationValidationBatch, type MutationValidationFailure, type MutationValidationHooksOptions, type MutationValidationMutation, type MutationValidationResult, type OAuthRefreshHookContext, type OpenAIParams, type OpenRouterParams, type Preset, type ProgressNudgeKind, type ProgressNudgeMessage, type PromptAudioPart, type PromptDocumentPart, type PromptImagePart, type PromptPart, type PromptTextPart, type PromptVideoPart, type Provider, type ProviderCapabilities, type ReadStateEntry, type ReadStateMap, type RemoteStoreOptions, type RepeatGuardConfig, type RepeatGuardToolMatcher, type ResolveContentRef, type ResolveContentRefBlock, type RetryConfig, type RunHookMap, type SandboxProvider, type Session, type SessionContentBlock, type SessionData, type SessionEndStatus, type SessionHookContext, type SessionMessage, type SessionRun, type SessionStore, type SessionTurn, type SkillConfig, type SkillResource, type SkillsConfig, type SpawnConfig, type SpawnHookContext, type SpawnToolOptions, type SpawnToolState, type StreamCallbacks, type StreamHookContext, type StreamOptions, type SuccessfulMutationOutcome, type TaskEntry, type TaskExitInfo, type TaskHandle, type TaskStallInfo, type ThinkingLevel, type ToolCall, type ToolContext, type ToolDef, type ToolHookContext, type ToolMap, type ToolOutcome, type ToolResult, type ToolResultAudioContent, type ToolResultContent, type ToolResultDocumentContent, type ToolResultDocumentRefContent, type ToolResultImageContent, type ToolResultImageRefContent, type ToolResultMetadata, type ToolResultTextContent, type ToolResultVideoContent, type ToolSpec, type TurnFinishReason, type TurnResult, type TurnUsage, type ValidationResult, flattenTurns, matchesContextExceeded, statsByModel, toolOutputBudgetByteLength, toolOutputByteLength, toolResultToText };
|
|
5
|
+
export { type Agent, AgentAbortedError, type AgentBehavior, AgentBudgetExceededError, type AgentClock, AgentContextExceededError, type AgentHookMap, type AgentHooks, type AgentOptions, AgentProviderError, type AgentRunOptions, type AgentStats, AgentToolNotAllowedError, type AnthropicParams, type BackgroundTaskStatus, CONTEXT_EXCEEDED_MESSAGE_PATTERNS, type CerebrasParams, type ChildAgent, type ChildRunStats, type ClassifiedError, type ClassifiedErrorKind, type ContextCapabilities, type ContextHardening, type ContextType, type CreateSessionOptions, DEFAULT_AGENT_CLOCK, type DetachedTasksCapability, type ExecResult, type ExecutionContext, type ExecutionHandle, type InteractionToolOptions, type McpConnection, type McpServerConfig, type McpToolHookContext, type ModelUsage, type MutationValidationBatch, type MutationValidationFailure, type MutationValidationHooksOptions, type MutationValidationMutation, type MutationValidationResult, type OAuthRefreshHookContext, type OpenAIParams, type OpenRouterParams, type Preset, type ProgressNudgeKind, type ProgressNudgeMessage, type PromptAudioPart, type PromptDocumentPart, type PromptImagePart, type PromptPart, type PromptTextPart, type PromptVideoPart, type Provider, type ProviderCapabilities, type ReadStateEntry, type ReadStateMap, type RemoteStoreOptions, type RepeatGuardConfig, type RepeatGuardToolMatcher, type ResolveContentRef, type ResolveContentRefBlock, type RetryConfig, type RunHookMap, type SandboxProvider, type Session, type SessionContentBlock, type SessionData, type SessionEndStatus, type SessionHookContext, type SessionMessage, type SessionRun, type SessionStore, type SessionTurn, type SkillConfig, type SkillResource, type SkillsConfig, type SpawnConfig, type SpawnHookContext, type SpawnToolOptions, type SpawnToolState, type StagedToolState, type StreamCallbacks, type StreamHookContext, type StreamOptions, type SuccessfulMutationOutcome, type TaskEntry, type TaskExitInfo, type TaskHandle, type TaskStallInfo, type ThinkingLevel, type ToolCall, type ToolContext, type ToolDef, type ToolHookContext, type ToolMap, type ToolOutcome, type ToolResult, type ToolResultAudioContent, type ToolResultContent, type ToolResultDocumentContent, type ToolResultDocumentRefContent, type ToolResultImageContent, type ToolResultImageRefContent, type ToolResultMetadata, type ToolResultTextContent, type ToolResultVideoContent, type ToolSpec, type ToolStateController, type ToolStateOp, type ToolStateStore, type ToolStateView, type TurnFinishReason, type TurnResult, type TurnUsage, type ValidationResult, flattenTurns, matchesContextExceeded, statsByModel, toolOutputBudgetByteLength, toolOutputByteLength, toolResultToText };
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -839,6 +839,8 @@ Defaults: unlimited turns (set `maxTurns` as a safety net), model-driven output
|
|
|
839
839
|
|
|
840
840
|
Tools with side effects or non-deterministic outputs MUST NOT be listed (no safety net beyond the consumer hasher).
|
|
841
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
|
+
|
|
842
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.
|
|
843
845
|
|
|
844
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.
|
package/docs/PLUGINS.md
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# Agent Plugins
|
|
2
|
+
|
|
3
|
+
Support for [Agent Plugins](https://agent-plugins.org) 1.0.0 — an open, vendor-neutral package format for reusable agent components. A plugin is a directory with a manifest and its components at fixed locations; any conformant client can load it.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
my-plugin/
|
|
7
|
+
├── plugin.json manifest (required)
|
|
8
|
+
├── skills/ one directory per Agent Skill
|
|
9
|
+
│ └── deploy/
|
|
10
|
+
│ └── SKILL.md
|
|
11
|
+
└── mcp.json MCP server configuration
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { loadAgentPlugin } from 'zidane/plugins'
|
|
16
|
+
|
|
17
|
+
const plugin = await loadAgentPlugin('./my-plugin')
|
|
18
|
+
if (plugin.ok) {
|
|
19
|
+
console.log(plugin.manifest.name, plugin.skills.length, plugin.mcpServers.length)
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Agent Plugins deliberately does **not** redefine either component format — skills are Agent Skills, MCP config is MCP config. So `zidane/plugins` owns only the packaging contract and hands back plain `SkillConfig[]` / `McpServerConfig[]` that every other zidane surface already consumes.
|
|
24
|
+
|
|
25
|
+
## Why a separate loader
|
|
26
|
+
|
|
27
|
+
zidane already reads both component types, but both existing readers are deliberately *lenient*: `discoverSkills` recurses into nested category directories, and `normalizeMcpServers` infers a transport from the presence of `command` / `url` and accepts `httpUrl` / `sseUrl` / `type: "http"` aliases. That leniency is correct for a file the user wrote, and wrong for a third-party package — guessing on behalf of a stranger's `mcp.json` is how you spawn a command the author never wrote.
|
|
28
|
+
|
|
29
|
+
`zidane/plugins` is the strict layer: closed schemas, immediate-children-only discovery, and a filesystem boundary. It never loosens the existing readers; it wraps them.
|
|
30
|
+
|
|
31
|
+
## Discovery
|
|
32
|
+
|
|
33
|
+
Plugins are found in four locations, in precedence order — the same project/user pair every other zidane discovery surface uses:
|
|
34
|
+
|
|
35
|
+
1. `{project}/.agents/plugins/`
|
|
36
|
+
2. `{project}/.zidane/plugins/`
|
|
37
|
+
3. `~/.agents/plugins/`
|
|
38
|
+
4. `~/.zidane/plugins/`
|
|
39
|
+
|
|
40
|
+
Each **immediate child directory** is one plugin. Nothing is searched below that, and a child with no `plugin.json` is silently ignored — dropping a README next to your plugins is not an error.
|
|
41
|
+
|
|
42
|
+
`.agents/plugins/` is the portable location: Agent Plugins is vendor-neutral, so there is no foreign-agent or Ruler import variant the way there is for skills and MCP servers.
|
|
43
|
+
|
|
44
|
+
Identity is the **manifest** name, not the directory name. Two directories called `tools/` shipping different plugins are two plugins; the same plugin vendored at two scopes is one, and the project copy wins.
|
|
45
|
+
|
|
46
|
+
Discovered plugins are enabled by default and gated by `enabledPlugins` in settings. A plugin that fails to load still appears — as a read-only row explaining why — rather than vanishing.
|
|
47
|
+
|
|
48
|
+
### Extra roots for development
|
|
49
|
+
|
|
50
|
+
`ZIDANE_PLUGIN_DIRS` adds scan roots, mirroring `ZIDANE_EXTENSION_DIRS`:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
ZIDANE_PLUGIN_DIRS=/repo/plugins zidane
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Each entry is a **root** whose immediate children are plugins (`<root>/<name>/plugin.json`), not a single plugin directory. Multiple roots use the platform path delimiter.
|
|
57
|
+
|
|
58
|
+
This exists because all four default roots are dot-directories and `.zidane/` is gitignored — so a repo has nowhere *tracked* to keep example or first-party plugins. Extra roots are appended **after** the four defaults and scoped `project`, so a real project or user plugin always wins a name collision against a dev root.
|
|
59
|
+
|
|
60
|
+
This repo uses it to dogfood both load paths in `npm run tui`:
|
|
61
|
+
|
|
62
|
+
| Plugin | Route | Origin shown |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `plugins/hello-plugin` | disk, via `ZIDANE_PLUGIN_DIRS=../plugins` | `[project: plugins/hello-plugin]` |
|
|
65
|
+
| `extensions/plugin-host/vendor/vendored-plugin` | `ctx.loadAgentPlugin` | `[via extension: plugin-host]` |
|
|
66
|
+
|
|
67
|
+
Their skills (`plugin-smoke-test`, `vendored-smoke-test`) double as the manual verification scripts for plugin loading.
|
|
68
|
+
|
|
69
|
+
### Project-scope trust
|
|
70
|
+
|
|
71
|
+
A project plugin's stdio MCP servers run, the same as those in a project `.zidane/mcps.json` beside them. There is no extra gate, deliberately: holding plugins to a stricter rule than the MCP config sitting next to them would be inconsistent rather than safer, since both are things you or your repo placed on purpose.
|
|
72
|
+
|
|
73
|
+
Stated plainly, the exposure that comes with that: a cloned repo carrying `.agents/plugins/evil/mcp.json` gets its stdio command spawned on the first agent bootstrap. That is today's behavior for project `mcps.json` too. If you don't want it, disable the plugin in settings, or set `enabledPlugins` to an allowlist that omits it.
|
|
74
|
+
|
|
75
|
+
(The `dropStdio` carve-out in `chat/mcps-discovery.ts` is narrower than it looks — it applies to **Ruler** files specifically, because `importRulerConfigs` defaults on and would otherwise execute a repo-committed foreign config unprompted.)
|
|
76
|
+
|
|
77
|
+
## Loading a plugin
|
|
78
|
+
|
|
79
|
+
### From an extension
|
|
80
|
+
|
|
81
|
+
The primary entry point. One call registers everything the plugin ships:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { defineExtension } from 'zidane'
|
|
85
|
+
|
|
86
|
+
export default defineExtension({
|
|
87
|
+
name: 'my-toolkit',
|
|
88
|
+
capabilities: ['plugins'],
|
|
89
|
+
async setup(ctx) {
|
|
90
|
+
const plugin = await ctx.loadAgentPlugin('./vendor/deploy-tools')
|
|
91
|
+
if (!plugin.ok)
|
|
92
|
+
ctx.logger.warn('deploy-tools failed to load')
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`dir` may be absolute or relative to the extension's own directory, so an extension can vendor plugins inside itself. Inline extensions (no directory) resolve against `ctx.paths.cwd`.
|
|
98
|
+
|
|
99
|
+
Options, when you need them:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
await ctx.loadAgentPlugin({
|
|
103
|
+
dir: './vendor/deploy-tools',
|
|
104
|
+
skills: true, // register skills/ (default true)
|
|
105
|
+
mcp: true, // register mcp.json servers (default true)
|
|
106
|
+
namePrefix: 'acme', // rename to `acme-<name>` (default none)
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Three things worth knowing:
|
|
111
|
+
|
|
112
|
+
- **It never throws for plugin-level problems.** A malformed manifest, an escaping symlink or an invalid skill comes back as `ok: false` / diagnostics and is logged to the extension's logger. One bad package cannot fail your `setup()`.
|
|
113
|
+
- **Await it inside `setup()`.** Registration closes when `setup()` resolves. A load still in flight at that point is dropped with a warning rather than mutating an already-published registry.
|
|
114
|
+
- **Components inherit the extension's scope.** A project-scoped extension's plugin is project-scoped, exactly as `ctx.registerSkill` behaves.
|
|
115
|
+
|
|
116
|
+
### From the SDK
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { loadAgentPlugin } from 'zidane/plugins'
|
|
120
|
+
|
|
121
|
+
const plugin = await loadAgentPlugin(dir, {
|
|
122
|
+
source: 'user', // scope stamped on discovered skills (default 'project')
|
|
123
|
+
skills: true, // discover skills/ (default true)
|
|
124
|
+
mcp: true, // discover mcp.json (default true)
|
|
125
|
+
namePrefix: 'acme', // rename to `acme-<name>` (default none)
|
|
126
|
+
dataRoot: '/custom/parent', // ${PLUGIN_DATA} parent; name is appended
|
|
127
|
+
dataDir: '/exact/dir', // or pin one plugin's directory outright
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## `plugin.json`
|
|
132
|
+
|
|
133
|
+
The manifest is a **closed** schema, validated locally — the spec forbids retrieving a schema while loading. `$schema` and `name` are required; everything else is optional metadata.
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
138
|
+
"name": "deploy-tools",
|
|
139
|
+
"version": "1.2.0",
|
|
140
|
+
"description": "Deployment skills and a release MCP server",
|
|
141
|
+
"author": { "name": "Acme", "email": "dev@acme.example" },
|
|
142
|
+
"homepage": "https://acme.example",
|
|
143
|
+
"repository": "https://github.com/acme/deploy-tools",
|
|
144
|
+
"license": "MIT",
|
|
145
|
+
"keywords": ["deploy", "release"],
|
|
146
|
+
"extensions": { "dev.example": { "anything": true } }
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`name` is 1–64 characters: lowercase letters, digits, `-` and `.`, alphanumeric at both ends, no `--` or `..`.
|
|
151
|
+
|
|
152
|
+
Metadata strings are type-checked only. A `license` that isn't valid SPDX, or a `homepage` that isn't a valid URL, does **not** reject the plugin — the spec keeps identity portable and leaves semantic policing to registries.
|
|
153
|
+
|
|
154
|
+
Exactly two violations are non-fatal, per spec:
|
|
155
|
+
|
|
156
|
+
| Violation | Behavior |
|
|
157
|
+
|---|---|
|
|
158
|
+
| Unknown top-level field | Reported, ignored, loading continues |
|
|
159
|
+
| `extensions` is not an object | Reported, ignored, loading continues |
|
|
160
|
+
|
|
161
|
+
Every other schema violation **rejects the whole plugin**, and rejection happens *before* component discovery — a plugin with a broken manifest never has its skills read or its servers spawned.
|
|
162
|
+
|
|
163
|
+
`extensions` member *values* are never validated. The spec says to ignore namespaces the client does not implement without inspecting them, so a client validates only its own namespace at the point of use.
|
|
164
|
+
|
|
165
|
+
## `skills/`
|
|
166
|
+
|
|
167
|
+
Each immediate child directory containing a `SKILL.md` that resolves to a regular file is one skill. The format is plain [Agent Skills](./SKILL.md) — same parser as `~/.agents/skills`.
|
|
168
|
+
|
|
169
|
+
Nested descendants are **not** searched. `skills/category/nested/SKILL.md` is invisible, even though zidane's own skill discovery would find it. Bundling a non-skill directory under `skills/` is legal and silently ignored.
|
|
170
|
+
|
|
171
|
+
Every discovered skill is stamped with `metadata['zidane.plugin']` naming the plugin that shipped it.
|
|
172
|
+
|
|
173
|
+
## `mcp.json`
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
|
178
|
+
"mcpServers": {
|
|
179
|
+
"release": {
|
|
180
|
+
"type": "stdio",
|
|
181
|
+
"command": "uvx",
|
|
182
|
+
"args": ["release-mcp", "--root", "${PLUGIN_ROOT}"],
|
|
183
|
+
"env": { "CACHE_DIR": "${PLUGIN_DATA}/cache" },
|
|
184
|
+
"cwd": "${PLUGIN_ROOT}"
|
|
185
|
+
},
|
|
186
|
+
"registry": {
|
|
187
|
+
"type": "streamable-http",
|
|
188
|
+
"url": "https://mcp.acme.example/v1",
|
|
189
|
+
"headers": { "X-Client": "zidane" }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`$schema` must declare the **same** Agent Plugins version as `plugin.json`.
|
|
196
|
+
|
|
197
|
+
`type` is required and never inferred. Each transport has a closed key set:
|
|
198
|
+
|
|
199
|
+
| `type` | Permitted keys |
|
|
200
|
+
|---|---|
|
|
201
|
+
| `stdio` | `type`, `command`, `args`, `env`, `cwd` |
|
|
202
|
+
| `streamable-http` | `type`, `url`, `headers` |
|
|
203
|
+
| `sse` | `type`, `url`, `headers` |
|
|
204
|
+
|
|
205
|
+
An unknown field, an unknown transport, or a missing required field invalidates **that one entry**; its siblings still load. A malformed document, a version mismatch, or an unknown top-level field disables the MCP component for that plugin only — its skills are unaffected.
|
|
206
|
+
|
|
207
|
+
### Plugin variables
|
|
208
|
+
|
|
209
|
+
Two placeholders, and only two:
|
|
210
|
+
|
|
211
|
+
| Variable | Value |
|
|
212
|
+
|---|---|
|
|
213
|
+
| `${PLUGIN_ROOT}` | Symlink-resolved plugin root |
|
|
214
|
+
| `${PLUGIN_DATA}` | Dedicated writable directory, persistent across plugin updates |
|
|
215
|
+
|
|
216
|
+
They expand in `args`, `env` values and `cwd` — **nowhere else**. In `command`, `url` and `headers` the text stays literal, so a `${PLUGIN_ROOT}` in `command` is rejected rather than handed to the OS verbatim.
|
|
217
|
+
|
|
218
|
+
Expansion is single-pass and non-recursive: an expanded value containing `${PLUGIN_DATA}` does not expand again. Unknown `${...}` text is left alone — zidane's wider `${VAR}` / `${VAR:-default}` / `${env:VAR}` handling does **not** apply to plugin configs (they carry `preExpanded`, which suppresses it). That is deliberate: it keeps an ambient `PLUGIN_ROOT` environment variable from reaching fields the spec says must stay literal.
|
|
219
|
+
|
|
220
|
+
Both variables are always present in a stdio server's `env`, written last, and a plugin that tries to set either itself has that entry rejected.
|
|
221
|
+
|
|
222
|
+
`${PLUGIN_DATA}` defaults to `<dataDir>/plugin-data/<plugin-name>/` from `resolveStorageDirs` (honouring `ZIDANE_STORAGE_DIR` and XDG). It sits **outside** the plugin root so a plugin update — which replaces the package tree — cannot wipe it. Extension-loaded plugins get it inside the loading extension's storage root instead, so two extensions loading the same plugin don't share state.
|
|
223
|
+
|
|
224
|
+
The directory is created at **spawn** time, not load time. Loading is not consent: a host has to load every plugin it finds just to learn its name (identity lives in the manifest, so it can't filter first), and `enabledPlugins` only applies afterwards — creating on load left a directory behind for every plugin the user had switched off, on every discovery pass. A `cwd` inside `${PLUGIN_DATA}` is created along with it, since spawning into a non-existent directory fails with a bare `ENOENT`; a `cwd` under `${PLUGIN_ROOT}` is not, because that path is package content the author was supposed to ship.
|
|
225
|
+
|
|
226
|
+
Note the honest consequence: `ZIDANE_PREFIX` is *not* read by `resolveStorageDirs` — callers thread `prefix` explicitly, which disk discovery does.
|
|
227
|
+
|
|
228
|
+
### stdio commands
|
|
229
|
+
|
|
230
|
+
`command` is exactly one executable token — no shell, no argv splitting:
|
|
231
|
+
|
|
232
|
+
- **Bare name** (`uvx`, `node`) → PATH lookup by the OS.
|
|
233
|
+
- **`./`-relative** → resolved inside the plugin root, and it must exist.
|
|
234
|
+
- **Absolute** → rejected. It is neither a PATH lookup nor a package file.
|
|
235
|
+
|
|
236
|
+
`cwd` defaults to the plugin root and must stay inside the root or the data directory, checked both lexically and after symlink resolution.
|
|
237
|
+
|
|
238
|
+
### Remote servers
|
|
239
|
+
|
|
240
|
+
- HTTPS required unless the host is loopback.
|
|
241
|
+
- No userinfo credentials, no fragment.
|
|
242
|
+
- Configured headers are pinned to the URL's origin: redirects are followed manually and credentials are dropped the moment a hop leaves that origin. `authorization` is always protected, including the header the SDK's OAuth provider injects rather than reading from config.
|
|
243
|
+
- A redirect to non-loopback cleartext is refused outright, not merely stripped.
|
|
244
|
+
|
|
245
|
+
## The package boundary
|
|
246
|
+
|
|
247
|
+
Every package file must stay inside the symlink-resolved plugin root. Lexical checks are not enough — a `skills` symlink pointing at `~/.ssh` passes every one of them — so each path is resolved through `realpath` and re-tested, and required filesystem kinds are asserted.
|
|
248
|
+
|
|
249
|
+
Failures are isolated at the narrowest level that still holds:
|
|
250
|
+
|
|
251
|
+
| Problem | Consequence |
|
|
252
|
+
|---|---|
|
|
253
|
+
| Invalid manifest, or manifest outside the root | Whole plugin rejected, no components discovered |
|
|
254
|
+
| `skills/` or `mcp.json` invalid, wrong kind, or outside the root | That component type disabled; the other still loads |
|
|
255
|
+
| One skill invalid, unreadable, or outside the root | That skill skipped; siblings load |
|
|
256
|
+
| One `mcp.json` entry invalid | That server skipped; siblings load |
|
|
257
|
+
| Any other escaping path | Access denied for that path |
|
|
258
|
+
|
|
259
|
+
A **missing** fixed location is not an error. `components.skills === 'absent'` means the plugin ships no skills; `'invalid'` means they were rejected.
|
|
260
|
+
|
|
261
|
+
## Results and diagnostics
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
interface LoadedPlugin {
|
|
265
|
+
ok: boolean // false ⇒ plugin rejected, components never discovered
|
|
266
|
+
dir: string // root as requested, made absolute
|
|
267
|
+
root: string // symlink-resolved root — the boundary
|
|
268
|
+
manifest?: PluginManifest // absent iff !ok
|
|
269
|
+
skills: SkillConfig[]
|
|
270
|
+
mcpServers: McpServerConfig[]
|
|
271
|
+
components: {
|
|
272
|
+
skills: 'absent' | 'loaded' | 'invalid' | 'unsupported'
|
|
273
|
+
mcp: 'absent' | 'loaded' | 'invalid' | 'unsupported'
|
|
274
|
+
}
|
|
275
|
+
diagnostics: PluginDiagnostic[]
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Each diagnostic carries a `severity`, a stable machine-readable `code` (`manifest-unknown-field`, `skill-escapes-root`, `mcp-entry-transport-invalid`, …), a message, and optionally the `field` or `path` involved. `severity: 'error'` on a plugin that still returned `ok: true` means a narrow boundary fired, not that the plugin failed.
|
|
280
|
+
|
|
281
|
+
## Naming and collisions
|
|
282
|
+
|
|
283
|
+
zidane's skill catalog and MCP server names are single flat namespaces, so two plugins each shipping a `deploy` skill or a `github` server would collide. The spec leaves the policy to the client; zidane's default is first-found-wins with a collision diagnostic, plus opt-in `namePrefix` for the cases where you know you're loading several.
|
|
284
|
+
|
|
285
|
+
Server names are validated in the form they actually take in the tool catalog — `mcp_<server>_<tool>` — which means *after* `namePrefix` is applied, since a bad prefix can invalidate a name the plugin got right.
|
|
286
|
+
|
|
287
|
+
Collisions are resolved at the merge, not at load, so a plugin's settings row counts what the **package ships** rather than what ended up live. A plugin server whose name matches one in `mcps.json` is dropped (first-wins, `mcps.json` first), and a plugin skill whose name matches an extension's is shadowed. The MCPs and Skills tabs are the authority on what actually loaded; skill collisions emit `name-collision-shadowed`, server collisions are logged under `ZIDANE_DEBUG`. Pass `namePrefix` when loading plugins that might collide.
|
|
288
|
+
|
|
289
|
+
### The same plugin from both routes
|
|
290
|
+
|
|
291
|
+
A plugin present *both* on disk and vendored inside an extension is **one** plugin: the disk copy wins, and the extension's copy contributes nothing. One settings row, one checkbox, one set of skills and servers.
|
|
292
|
+
|
|
293
|
+
This has to hold on the contribution side, not just the display side. If both copies stayed live, that single checkbox would govern two of them, and the skills are the sharp end: both sets reach the catalog, where one shadows the other and earns a `name-collision-shadowed` warning about a collision the user never created.
|
|
294
|
+
|
|
295
|
+
The MCP half is the same shape but milder — both configs reach the merge, where a first-wins dedupe by name collapses them, so nothing is spawned twice. What's wrong there is that one checkbox nominally governs two records, and which copy survives is decided by merge order rather than by anything the user chose.
|
|
296
|
+
|
|
297
|
+
Disk wins even when the disk copy is *rejected*, so a broken installed copy doesn't quietly fall back to a vendored one while the row reads `rejected`.
|
|
298
|
+
|
|
299
|
+
## Settings
|
|
300
|
+
|
|
301
|
+
Plugins live in the **Extensions & Plugins** tab, interleaved alphabetically with extensions rather than in a separate section. They aren't parallel categories — a plugin can be loaded *by* an extension — so each row carries a kind badge and an origin instead:
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
[✓] git-flow v2.1.0 ext
|
|
305
|
+
Git helpers · [project: .zidane/extensions/git-flow]
|
|
306
|
+
[✓] deploy-tools v1.2.0 plugin
|
|
307
|
+
Deploy skills · [project: .agents/plugins/deploy-tools] · 3 skills, 1 server
|
|
308
|
+
[✓] registry-mcp v0.4.0 plugin
|
|
309
|
+
Registry access · [via extension: my-toolkit] · 1 server
|
|
310
|
+
✗ broken-thing plugin
|
|
311
|
+
rejected: manifest-name-invalid · [user: ~/.agents/plugins/broken-thing]
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The kind badge is a filled pill trailing the title line, so names stay left-aligned instead of being pushed right by a fixed-width column. Extensions get the neutral `border` tier because they're the majority — a saturated pill on nearly every row is noise — and plugins get the `model` accent so they stand out of that field.
|
|
315
|
+
|
|
316
|
+
Both colors derive from `ThemeColors` / `ThemeSurfaces` members every theme must define, rather than from `surfaces.chips` entries: that map is keyed by completion-provider id, and a third-party theme that never opted in would fall back to `chips.default` for both kinds, collapsing the very distinction the badge exists to draw.
|
|
317
|
+
|
|
318
|
+
Errored extensions and rejected plugins lead the list. A rejected plugin has no checkbox — there is nothing to enable — matching how an errored extension renders.
|
|
319
|
+
|
|
320
|
+
Searching matches a plugin's name, description and origin, plus the literal word `plugin`: type `plugin` to narrow to plugins, or an extension's name to find the plugins it loaded.
|
|
321
|
+
|
|
322
|
+
`ctrl+r` on this tab re-scans the plugin directories, so a package you just dropped into `.agents/plugins/` appears without a restart. It refreshes **plugins only** — an extension's `setup()` runs once per process, so extensions genuinely cannot be re-scanned in place, and calling it a generic refresh would over-promise. Extension-loaded plugins likewise only reload with their extension.
|
|
323
|
+
|
|
324
|
+
Toggling writes `enabledPlugins`, which gates the plugin's skills **and** its MCP servers together — for disk plugins and for extension-loaded ones alike. Extension-loaded plugins need a second filter pass to achieve that (`filterRegistryPlugins`), because their contributions are fanned into the loading extension's own sinks and `enabledExtensions` can only express whole-extension on/off.
|
|
325
|
+
|
|
326
|
+
Skills from an *enabled* plugin are force-included into `enabledSkills` when you have an allowlist there — enabling the plugin is the consent, and `enabledPlugins` is where you withhold it.
|
|
327
|
+
|
|
328
|
+
The gate is plugin-level only. A plugin's skills **are** listed in the Skills tab, badged `plugin:<name>`, but they carry no checkbox: the agent build force-includes every enabled plugin's skills, so a per-skill "off" would be persisted, flip the box, and then be overridden on the next build. Rather than ship a control whose off state doesn't hold, the row is read-only and the badge points at the plugin row that owns the switch. Turning off one skill of a plugin means disabling the plugin.
|
|
329
|
+
|
|
330
|
+
Extension-registered skills work the same way, badged `ext:<name>` and gated by `enabledExtensions`.
|
|
331
|
+
|
|
332
|
+
Real per-skill control would mean dropping that force-include and seeding contributed skill names into `enabledSkills` at enable time — a persistence change, not a rendering one.
|
|
333
|
+
|
|
334
|
+
## Conformance
|
|
335
|
+
|
|
336
|
+
zidane implements Agent Plugins 1.0.0: manifest validation, both component types, both required MCP transports plus legacy HTTP+SSE, plugin variables, and the failure-isolation ladder. Supported versions are listed in `SUPPORTED_AGENT_PLUGINS_VERSIONS`; a plugin targeting anything else is rejected with a diagnostic rather than loaded on a guess.
|
|
337
|
+
|
|
338
|
+
`test/plugins/conformance.test.ts` maps 1:1 to the [published checklist](https://agent-plugins.org/client-implementers/conformance) — one test per line, same order, same headings. Behavioral depth lives in the sibling suites; that file exists so conformance can be verified item by item instead of inferred from three test files. A checklist line with no test there is a claim nobody checked.
|
|
339
|
+
|
|
340
|
+
Not yet implemented: installing a plugin from a git/npm spec (`/plugin install`). Drop a package into one of the discovery directories, load it from an extension, or call `loadAgentPlugin` directly.
|
package/docs/RESTATE.md
CHANGED
|
@@ -35,6 +35,7 @@ Adapter helpers — pick the ones you need. Pass-through for everything else (`P
|
|
|
35
35
|
| `restateMcpToolWrap(ctx)` | `mcpToolWrap` hook (DISCOVERED MCP tools) | The MCP analogue of `wrapAgentTools`: journals every discovered MCP tool's result (soft-error-preserving). MCP tools are connected internally, past the public tool-wrapping seam, so the agent applies this on your behalf |
|
|
36
36
|
| `restateToolBatchExecutor({ promises })` | Tool scheduler | Opt-in Restate-native parallel join for concurrency-safe tool fleets |
|
|
37
37
|
| `restateSessionStore(ctx)` | `SessionStore` | Session header + turns + runs as virtual-object K/V state, header timestamps journal-sourced |
|
|
38
|
+
| `restateToolStateStore(ctx)` | `AgentOptions.toolState` | Backs `ToolContext.state` — durable key/value state tools keep across resumes and invocations (see [Durable tool state](#durable-tool-state)) |
|
|
38
39
|
| `restateAwakeableTool(options)` | `ToolDef` | Tool that parks on a Restate awakeable until an external resumer resolves it (human-in-the-loop waits) |
|
|
39
40
|
| `restateWaitBackground(options)` | `ExecutionContext.waitBackground` | Packages the race-safe durable wait for a background task's exit (mint → register-before-probe → already-exited fast-path → park → settle); the host supplies only persistence callbacks |
|
|
40
41
|
| `awakeableWithTimeout(ctx, opts)` | awakeable + `ctx.sleep` | Awakeable raced against a durable timer — resolves the resumer payload, or `null` on deadline. The low-level building block `restateWaitBackground` is built on |
|
|
@@ -45,7 +46,7 @@ Adapter helpers — pick the ones you need. Pass-through for everything else (`P
|
|
|
45
46
|
import * as restate from '@restatedev/restate-sdk'
|
|
46
47
|
import { createAgent } from 'zidane'
|
|
47
48
|
import { anthropic } from 'zidane/providers'
|
|
48
|
-
import { restateBehavior, restateClock, restateProvider, restateSessionStore, wrapAgentTools } from 'zidane/restate'
|
|
49
|
+
import { restateBehavior, restateClock, restateProvider, restateSessionStore, restateToolStateStore, wrapAgentTools } from 'zidane/restate'
|
|
49
50
|
import { createSession } from 'zidane/session'
|
|
50
51
|
import { shell } from 'zidane/tools'
|
|
51
52
|
|
|
@@ -63,6 +64,8 @@ const zidaneAgent = restate.object({
|
|
|
63
64
|
tools: wrapAgentTools({ shell }, ctx),
|
|
64
65
|
clock: restateClock(ctx),
|
|
65
66
|
session,
|
|
67
|
+
// Optional — only if your tools use `ctx.state`.
|
|
68
|
+
toolState: restateToolStateStore(ctx),
|
|
66
69
|
behavior: restateBehavior(),
|
|
67
70
|
})
|
|
68
71
|
|
|
@@ -193,7 +196,9 @@ On platforms without bidirectional streaming (AWS Lambda, sandbox-reattach runne
|
|
|
193
196
|
|
|
194
197
|
Pass `restateClock(ctx, { coalesce: true })` to collapse most of those round-trips: the clock journals a real anchor once, derives a monotonic logical sequence from it (`anchor`, `anchor+1`, …) without re-journaling, and re-anchors against the real clock only every `reanchorEvery` calls (default 8; tune via `{ coalesce: { reanchorEvery: n } }`). It stays fully replay-deterministic (anchors replay verbatim; the counter is a pure function of call order) and strictly monotonic.
|
|
195
198
|
|
|
196
|
-
The **tradeoff**: timestamps within a re-anchor window are logical — ordered and stable, but clustered near the window's anchor rather than wall-clock-accurate. Run `elapsed` (`endedAt - startedAt`) and per-turn `createdAt` lose precision down to the window granularity; live-only measurements (TTFT, `maxWallMs` budget) are unaffected because the loop reads those from `Date.now()` directly.
|
|
199
|
+
The **tradeoff**: timestamps within a re-anchor window are logical — ordered and stable, but clustered near the window's anchor rather than wall-clock-accurate. Run `elapsed` (`endedAt - startedAt`) and per-turn `createdAt` lose precision down to the window granularity; live-only measurements (TTFT, `maxWallMs` budget) are unaffected because the loop reads those from `Date.now()` directly.
|
|
200
|
+
|
|
201
|
+
Two subagent-specific consequences, both from differencing two coalesced draws: the elapsed figure in the spawn result string the **model reads** (`[sub-agent child-1] Completed in 2 turns (3ms)`) becomes a count of intervening clock draws rather than milliseconds, so a child that ran 40 seconds can report single-digit ms; and `createSpawnTool({ timeoutMs })`'s turn-boundary deadline measures the same logical scale, so it effectively counts draws too and won't fire on wall-clock time. Leave coalescing off if the model needs true durations or the child budget must track real time — hosts can always read the real duration off `ChildRunStats.stats.elapsed` (`spawn:complete` / `AgentStats.children`), which stays live `Date.now()`. Lower `reanchorEvery` ⇒ more accurate timestamps + more suspensions; higher ⇒ fewer suspensions + more clustering. The default (coalescing off) journals every `now()` and keeps wall-accurate timestamps, so opt in per deployment. Provider/tool `ctx.run` calls remain irreducible suspension points — coalescing only addresses the clock.
|
|
197
202
|
|
|
198
203
|
**Gotcha**: when you pair a `restateSessionStore` with a session, the store's own `generateTurnId` runs ahead of `clock.randomUUID()` (the loop short-circuits via `session?.generateTurnId() ?? clock.randomUUID()`). The store derives deterministic IDs from the persisted turn count (`turn_1`, `turn_2`, …) so turn IDs stay stable across replay and across later invocations on the same object key. If you wire a custom store, keep turn ID generation deterministic from persisted state or let the Restate-backed clock provide the fallback UUIDs.
|
|
199
204
|
|
|
@@ -344,6 +349,121 @@ createAgent({
|
|
|
344
349
|
|
|
345
350
|
The scheduler starts concurrency-safe, Restate-wrapped tools in submission order, joins each chunk through `RestatePromise.all()`, and emits tool results back to the model in original call order. Unsafe tools remain barriers. If a safe tool is not wrapped with `restateTool()` / `wrapAgentTools()`, Restate mode runs that chunk sequentially instead of falling back to native promise parallelism. This mode is opt-in so the TUI/headless harness keeps its native sliding-window scheduler.
|
|
346
351
|
|
|
352
|
+
## Subagents
|
|
353
|
+
|
|
354
|
+
`spawn` works under Restate, with one rule: **the spawn tool is never journaled — its child's steps are.**
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
const spawnTool = createSpawnTool({
|
|
358
|
+
persist: true, // child turns + runs land in the parent's session
|
|
359
|
+
shareHandle: true, // no unjournaled per-child sandbox mint
|
|
360
|
+
timeoutMs: 600_000,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
createAgent({
|
|
364
|
+
provider: restateProvider(myProvider, ctx, { streamCallbacks: 'suppress' }),
|
|
365
|
+
// Pass `spawn` in like any other tool — `wrapAgentTools` leaves it unwrapped.
|
|
366
|
+
tools: wrapAgentTools({ ...tools, spawn: spawnTool }, ctx),
|
|
367
|
+
clock: restateClock(ctx),
|
|
368
|
+
session,
|
|
369
|
+
behavior: restateBehavior({ maxTurns: 8 }),
|
|
370
|
+
})
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Why the spawn tool is excluded from wrapping
|
|
374
|
+
|
|
375
|
+
A child agent drives the *same* journaled provider (`ctx.provider`) and the *same* wrapped tools (`ctx.tools`) as its parent. If the spawn tool itself were wrapped, the child's first provider call would open a `ctx.run` **inside** the spawn tool's own `ctx.run`. That fails on the retry with `RestateError (570) Found a mismatch between the code paths taken during the previous execution and the paths taken during this execution` — observed against a real server, which is why `test/fixtures/restate-docker` now carries a subagent scenario.
|
|
376
|
+
|
|
377
|
+
`wrapAgentTools` therefore skips any tool declaring `ToolDef.runsNestedAgent` (the built-in `spawn` sets it; host orchestrator tools that call `agent.run` should too). The skip is keyed on that marker, not the tool name, so a host tool that merely happens to be called `spawn` still gets wrapped.
|
|
378
|
+
|
|
379
|
+
What you get instead is better: the child's provider calls and tool calls are journal entries **of the parent's invocation**. A crash mid-subagent replays the child's completed steps from the journal without re-running their bodies; only the unfinished step re-executes. Suspensions inside a child behave the same way.
|
|
380
|
+
|
|
381
|
+
**Escape hatch.** Wrapping a spawn tool explicitly (`restateTool(spawnTool, ctx, …)`) is legal *only* if that child is fully non-durable — a raw provider from a model catalog, unwrapped tools. Then the whole child collapses into ONE journal entry: cheap to replay, but a crash inside the child re-runs the entire child (re-billing every LLM call) and none of its side effects are individually journaled.
|
|
382
|
+
|
|
383
|
+
### What the child inherits
|
|
384
|
+
|
|
385
|
+
| Inherited | How |
|
|
386
|
+
|---|---|
|
|
387
|
+
| `AgentClock` | Automatic. The child stamps `SessionTurn.createdAt`, run start/end, and fallback turn ids from the parent's journaled clock, so a shared session gets byte-identical metadata across attempts. A child on the default clock mints fresh wall-clock values on every replay. |
|
|
388
|
+
| `AgentBehavior` | Automatic, including `toolBatchExecutor` — a child's own concurrency-safe fleets join through `RestatePromise.all()` like the parent's. |
|
|
389
|
+
| Wrapped tools + provider | Automatic (`ctx.tools` / `ctx.provider`) — this is what makes the child's steps journaled at all. |
|
|
390
|
+
| Session | `persist: true`. Child turn ids then come from `restateSessionStore`'s deterministic `(object key, seq)` mint. |
|
|
391
|
+
| Execution handle | `shareHandle: true`. Otherwise every child calls `execution.spawn()` — an unjournaled allocation, so a replayed attempt mints a fresh sandbox and orphans the first. |
|
|
392
|
+
| Durable tool state | **Never.** A child's `ctx.state` is an isolated in-memory map — see the note under Durable tool state below. |
|
|
393
|
+
|
|
394
|
+
### Per-child timeout
|
|
395
|
+
|
|
396
|
+
`createSpawnTool({ timeoutMs })` normally races the child against a `setTimeout`. That is a wall-clock branch: the same child can time out live and not on the replay, and the abort can land mid-stream, tearing a provider call down from inside its own journal entry. When the clock reports `journaled` (as `restateClock` does), the spawn tool leaves the timer off and instead samples the journaled clock at the child's **turn boundaries** — one sample before the run, one per completed turn — aborting the child at a boundary where nothing is in flight. Both mechanisms report `status: 'timeout'`.
|
|
397
|
+
|
|
398
|
+
Deliberately **not** implemented by borrowing `behavior.maxWallMs`, which looks like the natural fit: the loop derives the provider stream watchdogs from the wall budget (`providerWatchdogTimeouts` clamps start / total / idle by it), so a small `timeoutMs` would also shrink the child's TTFT ceiling and abort a live stream inside its `ctx.run`. Against a real server that fails the attempt with `awaitNext already pending` and then `RestateError 570` on the retry. The fixture's deadline scenario exists because that bug shipped for exactly one iteration.
|
|
399
|
+
|
|
400
|
+
Two consequences of the turn-boundary check: a child stuck inside ONE long tool call is not interrupted (use the runtime's own invocation timeout for a hard cut), and the measured elapsed time **spans the crash-to-retry gap**, because the run-start timestamp replays from before the crash. Budget accordingly.
|
|
401
|
+
|
|
402
|
+
### Limits
|
|
403
|
+
|
|
404
|
+
- **Sequential spawns only**, for two reasons. The weaker one: parallel children race on shared session state — the `child-N` label counter and the run-id re-sync both read `session.runs` at mint time — so label / run-id assignment can differ between attempts. The stronger one: children share the parent's *clock instance*, and `restateClock` carries mutable draw state (the `randomUUID` sequence counter, plus the anchor / call counter in `coalesce` mode). Concurrent children interleave those draws, so the same logical turn can draw a different id or timestamp ordinal on a replay — the `JournalMismatchError` class the clock module exists to prevent. `restateBehavior`'s default `maxConcurrentTools: 1` already gives you the safe posture; don't raise it for a turn that can spawn more than once.
|
|
405
|
+
- **Anything that reaches the transcript must come from `ctx.clock`.** The spawn result string is persisted in the parent's turns, and a state write's payload IS compared across attempts — so the elapsed figure in `[sub-agent child-1] Completed in 2 turns (35ms)` is sampled from the clock, not from `stats.elapsed` (which stays live `Date.now()` by design, for measurement accuracy). Getting this wrong fails the invocation at `set state` with `RestateError 570`, and only on an attempt that fails AFTER a child completed — a crash mid-child never produces the string, so the failure hides until a retry happens later in the run. The fixture's retry scenario exists for exactly that reason. The same rule is why the child inherits the clock at all.
|
|
406
|
+
- **The parent's spawn tool call is not itself journaled**, so it re-executes on replay: agent construction, MCP re-use, hook bubbling, and result-string formatting all run again. Keep host `onChildStart` / `spawn:*` handlers idempotent.
|
|
407
|
+
- **Don't set `behavior.maxWallMs` on a Restate-backed agent** (parent or child) unless you mean it: the provider stream watchdogs are clamped by it, so a tight budget aborts an in-flight stream from inside its journal entry rather than stopping cleanly at a turn boundary. Reach for the runtime's own invocation timeout, or `createSpawnTool({ timeoutMs })` for a child.
|
|
408
|
+
- **A child as its own Restate invocation is not supported.** That's the idiomatic answer for long or genuinely parallel children (own journal, own suspension budget), but it needs a transport for `child:*` hook bubbling and stat rollup across the invocation boundary.
|
|
409
|
+
|
|
410
|
+
## Durable tool state
|
|
411
|
+
|
|
412
|
+
The problem it solves: **a tool cannot keep state in a closure.** A journaled result skips the tool body on replay, so a `consumed = true` written inside `execute` never happens the second time around — while the closure itself was rebuilt from the original input. The tool then re-serves a payload it already delivered.
|
|
413
|
+
|
|
414
|
+
`ToolContext.state` is a key/value map that survives replays, suspensions, and whole invocations:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
const readErrors: ToolDef = {
|
|
418
|
+
spec: { name: 'read_errors', description: 'Report build errors once', inputSchema },
|
|
419
|
+
async execute(_input, ctx) {
|
|
420
|
+
if (ctx.state?.get<boolean>('delivered'))
|
|
421
|
+
return 'already delivered'
|
|
422
|
+
ctx.state?.set('delivered', true)
|
|
423
|
+
return problems
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
createAgent({
|
|
428
|
+
tools: wrapAgentTools({ read_errors: readErrors }, ctx),
|
|
429
|
+
toolState: restateToolStateStore(ctx),
|
|
430
|
+
})
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### The three phases
|
|
434
|
+
|
|
435
|
+
Reads are synchronous by design. A wrapped tool body runs inside `ctx.run`, and Restate forbids context use in there (no `ctx.get`, no `ctx.sleep`, no nested `ctx.run`) — so the durable I/O happens on either side of the body instead:
|
|
436
|
+
|
|
437
|
+
| Phase | When | Where |
|
|
438
|
+
|---|---|---|
|
|
439
|
+
| **hydrate** | Once per **agent instance**, before the first tool dispatch | Agent, outside every journaled action — one `ctx.get` |
|
|
440
|
+
| **stage** | Inside the tool body | In memory; `set` / `delete` record ops, they are not written through |
|
|
441
|
+
| **commit** | When the call settles | Loop, outside the journaled action — one `ctx.set` |
|
|
442
|
+
|
|
443
|
+
The load is memoized on the controller, and the controller is built once in `createAgent` — so a long-lived agent doing many runs loads exactly once. Under Restate that's one agent per invocation and the distinction doesn't bite, but elsewhere it means a resident agent never picks up state another process wrote to the same store out of band. Build a fresh agent (or a fresh controller) per unit of work if you need that.
|
|
444
|
+
|
|
445
|
+
The invariant that makes it replay-safe: **a staged mutation is committed only if it came back out of a journal entry.** `restateTool` reads the staged ops inside its journaled closure and ships them out as part of the journaled value (`{ __zidaneToolState: 1, ops, output }`), then re-seeds them outside it. On replay the body never runs, the ops come from the journal, and the commit re-issues an identical `ctx.set`. Attempts that produced no journal entry (control error, abort, dead process) have their staged ops dropped — the replay re-runs the body and stages them again.
|
|
446
|
+
|
|
447
|
+
### Semantics
|
|
448
|
+
|
|
449
|
+
- **Envelope is conditional.** A call that writes no state journals exactly what it journaled before this feature existed, so in-flight invocations replay unchanged across the deploy. What *does* change the journal shape is configuring `toolState` at all (it adds the run-start `ctx.get`) — drain in-flight invocations before rolling it out.
|
|
450
|
+
- **Commit order is call-index order**, never completion order — including durable fleets joined through `RestatePromise.all()`. Two parallel tools writing the same key would otherwise produce a nondeterministic write sequence.
|
|
451
|
+
- **A write becomes visible when its call commits**, not when it is staged. Views read through to the live snapshot, so a call that runs *after* a sibling committed does see that write (the sequential path `restateBehavior` defaults to reads back the previous call's write). A durable fleet commits only once the whole batch has settled, so calls racing inside one batch never see each other.
|
|
452
|
+
- **Errors still commit.** A soft tool failure journals its sentinel and its delta in the same entry, so the write is honored — live and replay agree.
|
|
453
|
+
- **A store failure drops the write, it doesn't fail the call.** The tool body already ran, so failing the run over a bookkeeping write would be worse than losing it — but losing it silently is worse still, since a dropped one-shot flag re-arms the exact bug this state prevents. A rejecting `commit` fires the `tool-state:commit-failed` hook with the ops that did NOT land (replaying them by hand is a legitimate recovery), and falls back to an unconditional stderr line when nothing is listening; set `handled = true` in your handler to own the reporting. A throwing handler can't take the dispatch path down with it. Make `commit` idempotent and retry inside it if the write matters more than latency.
|
|
454
|
+
- **Oversized values are rejected where they're written.** `ToolStateView.set` enforces a per-value budget (`DEFAULT_MAX_VALUE_BYTES`, 64 KiB of JSON; override with `restateToolStateStore(ctx, { maxValueBytes })`) and throws inside the tool body rather than letting the write fail at commit time. That matters more under Restate than elsewhere: an oversized state value doesn't fail politely, it fails the invocation with `RT0003` (MessageSizeLimit) and retries it under the configured policy — a fat write is a poison pill, not a dropped write. Non-serializable values (cycles, functions) are rejected the same way.
|
|
455
|
+
- **A failing `load` fails the run**, deliberately asymmetric with `commit`: running on an empty snapshot would re-arm the one-shot bugs this state exists to prevent. It throws from `agent.run()` on the normal error path (`errorRun` + `agent:done` still fire), and the memo is cleared so the next run retries.
|
|
456
|
+
- **`set(key, undefined)` is a deletion.** It is what a read would report anyway, and an undefined value is not JSON-stable across the journal.
|
|
457
|
+
- **Two-phase tools** (`durableStart`) journal their delta on the final entry (`wait.result` / `attach`). A write staged in `start` is lost if the process dies before the wait completes; write state in the final phase when it must be crash-durable.
|
|
458
|
+
- **Keep values small and JSON-serializable.** They cross a journal and land in virtual-object state, which Restate size-caps: flags, ids, counters, cursors — not transcripts.
|
|
459
|
+
- **Not inherited by subagents.** A child you construct yourself can take the same store; `createSpawnTool` has **no** `shareToolState` equivalent to `shareReadState`, so tools running inside a *spawned* subagent get an isolated in-memory map and their writes never reach the parent's store. This is the one thing a subagent does NOT get under Restate — everything else (journaled clock, wrapped tools/provider, session, handle) is covered in § Subagents. A child's one-shot delivery is therefore not crash-safe; keep such tools in the parent, or hand the child an explicitly constructed agent with the same `toolState` store.
|
|
460
|
+
- **The store is bound to one invocation's `ctx`.** Build it inside the handler, like `restateSessionStore` and `restateProvider`. An agent constructed once at module scope and reused across invocations would commit through a dead context — and never re-hydrate. `toolState` reads like agent-lifetime configuration, which makes this easier to get wrong than the other ctx-bound wrappers.
|
|
461
|
+
- **The stored map is a schema you own.** It is one JSON object under one state key, with no versioning or migration built in. Renaming a key or changing a value's shape has to tolerate the old value still being there from a previous deploy; `restateToolStateStore(ctx, { key })` gives you a clean namespace when a migration is easier than a tolerant read.
|
|
462
|
+
|
|
463
|
+
### Without Restate
|
|
464
|
+
|
|
465
|
+
`ToolStateStore` is two methods (`load`, `commit`) and nothing in it is Restate-specific — implement it against Redis, Postgres, or a file for other hosts. With no store configured `ctx.state` is still present and functional, backed by an in-memory map scoped to the agent instance, so a tool written against it behaves identically on the local CLI path and only loses durability.
|
|
466
|
+
|
|
347
467
|
## Recommended `AgentBehavior` for Restate deployments
|
|
348
468
|
|
|
349
469
|
```ts
|
|
@@ -525,7 +645,17 @@ A runtime with a hard per-invocation CPU/wall cap (Cloudflare's ~5-minute limit)
|
|
|
525
645
|
|
|
526
646
|
## Tests
|
|
527
647
|
|
|
528
|
-
`test/restate.test.ts` covers each wrapper individually + end-to-end agent.run() with the clock + provider in place. A fake `RestateContextLike` records `ctx.run` calls and can be pre-seeded with a journal to verify replay semantics (inner fn not invoked, cached value returned verbatim).
|
|
648
|
+
`test/restate.test.ts` covers each wrapper individually + end-to-end agent.run() with the clock + provider in place. A fake `RestateContextLike` records `ctx.run` calls and can be pre-seeded with a journal to verify replay semantics (inner fn not invoked, cached value returned verbatim). Its `durable tool state` block pairs a recording ctx with a replaying one to assert the live→replay contract end to end: the delta is journaled with the output, replay restores it without entering the body, and the commit is identical.
|
|
649
|
+
|
|
650
|
+
`test/tool-state.test.ts` covers the runtime-agnostic half — snapshot hydration, staging, commit-on-settle, the size guard, the commit-failure hook, the in-memory default, and the loop round-trip.
|
|
651
|
+
|
|
652
|
+
`bun run restate:fixture:test` is the end-to-end suite against a **real** Restate server in Docker, and is deliberately **not** part of CI — run it locally when you touch the adapter. It boots the container, supervises the fixture service, and drives ten scenarios through the real ingress.
|
|
653
|
+
|
|
654
|
+
Five cover the adapter itself: baseline agent run; crash → replay (the service process is killed mid-invocation and respawned, so the journal, not a mock, decides what re-runs); per-object-key state isolation; commit-after-soft-failure; and an awakeable suspend/resume.
|
|
655
|
+
|
|
656
|
+
Five cover **subagents**, each pinned on an on-disk ledger that has to outlive the process: a crash inside the child; an awakeable park inside the child (suspend → external resume, with the child's `register` phase required to fire exactly once, or the resumed awakeable id would be one nobody resolves); a depth-2 grandchild crash, two un-journaled spawn frames deep; a journaled-deadline trip; and an attempt that fails **without** a process death, so the journal replays against warm module state — that one asserts an unchanged boot count to prove it took the retry path and not the crash path.
|
|
657
|
+
|
|
658
|
+
Both subagent bugs found in review — the `maxWallMs` watchdog clamp and the live `elapsed` inside a persisted string — were caught here and nowhere else. The suite fails if the crash never forced a respawn, so it cannot pass without actually exercising replay. This is the only coverage where replay, suspension, and virtual-object state semantics come from the SDK rather than from our reading of it.
|
|
529
659
|
|
|
530
660
|
`test/start.test.ts` covers the local `bun start --restate` runner helpers: option parsing, deployment registration, ingress invocation timeout wiring, readiness/service probing, runtime-state cleanup, cached completion, changed-input rejection, and `status` / `history` handlers.
|
|
531
661
|
|