fix(scope): close remaining ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 03:51:55 +08:00
parent 3dca90261c
commit 36b8370027
79 changed files with 3957 additions and 817 deletions

View File

@@ -343,24 +343,6 @@ export interface Config {
Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts) Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts)
## `@deepseek-ai/dsh-invariants`
Requires: `sessions`
```ts config-catalog
/** Plugin config. */
export interface Config {
/**
* Deep-freeze logged session-event data so mutating a logged event throws.
* Default true — this plugin only runs in dev/test, where freezing is the
* point. Set false to assert the event contract without freezing.
*/
freeze?: boolean
}
```
Source: [`packages/support/invariants/src/index.ts:48`](../packages/support/invariants/src/index.ts)
## `@deepseek-ai/dsh-llm-deepseek` ## `@deepseek-ai/dsh-llm-deepseek`
Requires: `llm` Requires: `llm`
@@ -583,7 +565,7 @@ export interface Config {
} }
``` ```
Source: [`packages/skill/skill/src/index.ts:112`](../packages/skill/skill/src/index.ts) Source: [`packages/skill/skill/src/index.ts:114`](../packages/skill/skill/src/index.ts)
## `@deepseek-ai/dsh-skill-local` ## `@deepseek-ai/dsh-skill-local`
@@ -808,7 +790,7 @@ export interface Config {
} }
``` ```
Source: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts) Source: [`packages/core/system-prompt/src/index.ts:265`](../packages/core/system-prompt/src/index.ts)
## `@deepseek-ai/dsh-tool-cordis` ## `@deepseek-ai/dsh-tool-cordis`
@@ -1168,6 +1150,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
- `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts))
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))

View File

@@ -34,9 +34,9 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
## Rules of the execute() contract ## Rules of the execute() contract
- **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. - **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input.
- **Registration snapshots your definition.** Parameters must be losslessly JSON-serializable; the registry validates and clones them, copies the scalar fields, binds each callback once to your definition as its method receiver, and freezes the stored record. Reassigning `definition.execute` after registration does not hot-swap the tool—dispose and register a new definition through the owning effect instead. Deliberate mutable state inside the callback's closure or receiver remains ordinary plugin state. - **Registration snapshots your definition.** Parameters must be losslessly JSON-serializable; the registry reads them once and materializes the detached stored value in one recursive pass, copies the scalar fields, binds each callback once to your definition as its method receiver, and freezes the stored record. Reassigning `definition.execute` after registration does not hot-swap the tool—dispose and register a new definition through the owning effect instead. Deliberate mutable state inside the callback's closure or receiver remains ordinary plugin state.
- **Execution identity is protected.** The registry requires `arguments` to survive lossless-JSON validation before and after cloning, freezes the detached value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline. - **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline.
- **Throwing or returning non-JSON data means isError.** The registry catches anything `execute()` throws and validates the complete post-policy result as losslessly JSON-serializable before final observers run. A throw, malformed result, or non-JSON content/context/meta becomes `{isError: true}` so the live outcome cannot succeed and then fail at the durable log. Use errors for infrastructure failures (bad input, spawn errors, aborts), but report domain failures in the result text instead (for example, tool-bash returns `[exit code: 9]` with `isError: false` because the model decides what a failing command means). - **Throwing or returning non-JSON data means isError.** The registry catches anything `execute()` throws and materializes the complete post-policy result as lossless JSON before final observers run. A throw, malformed result, or non-JSON content/context/meta becomes `{isError: true}` so the live outcome cannot succeed and then fail at the durable log. Use errors for infrastructure failures (bad input, spawn errors, aborts), but report domain failures in the result text instead (for example, tool-bash returns `[exit code: 9]` with `isError: false` because the model decides what a failing command means).
- **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Honor `exec.signal`.** Cancel in-flight work when it fires.
- **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`. - **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`.
- **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: '<name>'}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). - **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: '<name>'}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch).

View File

@@ -285,7 +285,7 @@ A skill provider became resolvable in the `ctx.skills` registry. Consumers can o
'skill/provider-added'(provider: SkillProvider): void 'skill/provider-added'(provider: SkillProvider): void
``` ```
Source: [`packages/skill/skill/src/index.ts:130`](../../packages/skill/skill/src/index.ts) Source: [`packages/skill/skill/src/index.ts:132`](../../packages/skill/skill/src/index.ts)
### `skill/provider-removed` — emit ### `skill/provider-removed` — emit
@@ -295,7 +295,7 @@ A skill provider left the registry because its plugin fiber was disposed.
'skill/provider-removed'(name: string): void 'skill/provider-removed'(name: string): void
``` ```
Source: [`packages/skill/skill/src/index.ts:136`](../../packages/skill/skill/src/index.ts) Source: [`packages/skill/skill/src/index.ts:138`](../../packages/skill/skill/src/index.ts)
## `subagent/*` ## `subagent/*`
@@ -307,7 +307,7 @@ A started subagent run settled — emitted when SubagentRun.result resolves (any
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void 'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
``` ```
Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts) Source: [`packages/subagent/subagent/src/index.ts:115`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-added` — emit ### `subagent/provider-added` — emit
@@ -317,7 +317,7 @@ A provider became resolvable in the SubagentService registry. Consumers that der
'subagent/provider-added'(provider: SubagentProvider): void 'subagent/provider-added'(provider: SubagentProvider): void
``` ```
Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts) Source: [`packages/subagent/subagent/src/index.ts:76`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-removed` — emit ### `subagent/provider-removed` — emit
@@ -327,7 +327,7 @@ A provider left the registry (its plugin's fiber was disposed — an unload or a
'subagent/provider-removed'(name: string): void 'subagent/provider-removed'(name: string): void
``` ```
Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts) Source: [`packages/subagent/subagent/src/index.ts:87`](../../packages/subagent/subagent/src/index.ts)
### `subagent/start` — emit ### `subagent/start` — emit
@@ -337,7 +337,7 @@ A subagent run started — emitted only after SubagentRun.started fulfills, when
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void 'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
``` ```
Source: [`packages/subagent/subagent/src/index.ts:101`](../../packages/subagent/subagent/src/index.ts) Source: [`packages/subagent/subagent/src/index.ts:102`](../../packages/subagent/subagent/src/index.ts)
## `system-prompt/*` ## `system-prompt/*`
@@ -349,7 +349,7 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly> 'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
``` ```
Source: [`packages/core/system-prompt/src/index.ts:45`](../../packages/core/system-prompt/src/index.ts) Source: [`packages/core/system-prompt/src/index.ts:46`](../../packages/core/system-prompt/src/index.ts)
### `system-prompt/change` — emit ### `system-prompt/change` — emit
@@ -359,7 +359,7 @@ A section, tool provider, variable provider, or protection was registered or unr
'system-prompt/change'(): void 'system-prompt/change'(): void
``` ```
Source: [`packages/core/system-prompt/src/index.ts:55`](../../packages/core/system-prompt/src/index.ts) Source: [`packages/core/system-prompt/src/index.ts:56`](../../packages/core/system-prompt/src/index.ts)
## `tools/*` ## `tools/*`

View File

@@ -40,7 +40,7 @@ list(): Agent[]
Types: [Agent](../core-data-structures/core.md) Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/index.ts:169`](../../packages/core/agent/src/index.ts) Source: [`packages/core/agent/src/index.ts:174`](../../packages/core/agent/src/index.ts)
## `ctx.approval` — `ApprovalService` ## `ctx.approval` — `ApprovalService`
@@ -189,7 +189,7 @@ Contracts every implementation MUST honor (a DB backend asserts them inside a tr
- **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded. - **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded.
- **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn). - **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn).
- **JSON-serializable data.** `SessionEventMap` is merge-extensible and `event.data` is typed only as `SessionEventMap[K]`, so append REJECTS non-JSON-serializable data with an error naming the offending event type. A backend snapshots (serializes/clones) each event when it buffers, since `session.events` hands out the live mutable object. - **JSON-serializable events.** `SessionEventMap` is merge-extensible, so append materializes each complete batch through the shared lossless-JSON boundary before buffering it. The public `session.events` view is immutable, but persistence still snapshots direct/replay callers at this independent trust boundary.
- **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization). - **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization).
```ts cordis-catalog ```ts cordis-catalog
@@ -220,7 +220,7 @@ list(): Session[]
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
``` ```
Source: [`packages/core/session/src/index.ts:427`](../../packages/core/session/src/index.ts) Source: [`packages/core/session/src/index.ts:608`](../../packages/core/session/src/index.ts)
## `ctx.skills` — `SkillService` ## `ctx.skills` — `SkillService`
@@ -233,7 +233,7 @@ async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>
``` ```
Source: [`packages/skill/skill/src/index.ts:157`](../../packages/skill/skill/src/index.ts) Source: [`packages/skill/skill/src/index.ts:159`](../../packages/skill/skill/src/index.ts)
## `ctx.subagents` — `SubagentService` ## `ctx.subagents` — `SubagentService`
@@ -246,7 +246,7 @@ list(): string[]
start(name: string, request: SubagentStartRequest): SubagentRun start(name: string, request: SubagentStartRequest): SubagentRun
``` ```
Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts) Source: [`packages/subagent/subagent/src/index.ts:161`](../../packages/subagent/subagent/src/index.ts)
## `ctx.systemPrompt` — `SystemPrompt` ## `ctx.systemPrompt` — `SystemPrompt`
@@ -260,7 +260,7 @@ protect(protection: PromptProtection): () => Promise<void> | void
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
``` ```
Source: [`packages/core/system-prompt/src/index.ts:379`](../../packages/core/system-prompt/src/index.ts) Source: [`packages/core/system-prompt/src/index.ts:380`](../../packages/core/system-prompt/src/index.ts)
## `ctx.tools` — `ToolRegistry` ## `ctx.tools` — `ToolRegistry`

View File

@@ -6,7 +6,7 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind
## Provider registry ## Provider registry
`ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. The registry validates candidates, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract. `ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. Each lookup snapshots its read-only options before provider work, and each provider candidate becomes registry-owned data while its opaque locator retains provider-owned identity. The registry validates candidates, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract.
```ts type-equiv ```ts type-equiv
interface SkillProvider { interface SkillProvider {
@@ -28,7 +28,7 @@ The shipped local provider scans roots in rank order:
| 400 | `user-dsh` | `<dshHome>/skills` | | 400 | `user-dsh` | `<dshHome>/skills` |
| 500 | `user-agents` | `<agentsHome>/skills` | | 500 | `user-agents` | `<agentsHome>/skills` |
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child, and DeepSeek Harness no longer ships built-in system skills from the local provider. Additional built-ins can be supplied later by another provider. The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider.
## Skill identity ## Skill identity
@@ -92,12 +92,12 @@ type SkillRegistration = Omit<SkillDefinition, 'provider'> & {
## Lookup and configuration ## Lookup and configuration
Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. If no git root is found, the local provider treats the supplied cwd itself as the project root. Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. The registry captures both fields once and providers receive the same read-only snapshot used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root.
```ts type-equiv ```ts type-equiv
interface SkillLookupOptions { interface SkillLookupOptions {
cwd?: string | undefined readonly cwd?: string | undefined
signal?: AbortSignal | undefined readonly signal?: AbortSignal | undefined
} }
``` ```

View File

@@ -81,7 +81,7 @@ type InferArgs<S extends SchemaSpec> = Simplify<
`defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs<typeof parameters>`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface. `defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs<typeof parameters>`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface.
Registration is a value boundary. `ToolRegistry.register()` validates `ToolDefinition.parameters` as lossless JSON before and after cloning, copies the scalar fields, binds the execute/presentation callbacks once to the original definition as their method receiver, and deep-freezes the stored record. Replacing a callback property on the caller-owned definition later does not change dispatch. `get()`/`visible()` expose only that frozen snapshot, while `schemas()` produces detached projections, so the model-visible and executable views cannot drift through a leaked mutable registry object. Registration is a value boundary. `ToolRegistry.register()` reads every top-level field once, validates fixed scalar and callback types, materializes `ToolDefinition.parameters` as detached lossless JSON in one recursive pass, binds the accepted execute/presentation callbacks once to the original definition as their method receiver, and deep-freezes the stored record. Replacing a callback property on the caller-owned definition later does not change dispatch. `get()`/`visible()` expose only that frozen snapshot, while `schemas()` produces detached projections, so the model-visible and executable views cannot drift through a leaked mutable registry object.
## Execution: extensible waterfalls plus monotonic policy ## Execution: extensible waterfalls plus monotonic policy
@@ -118,7 +118,7 @@ interface ToolExecution extends ToolExecutionInput {
} }
``` ```
`ToolExecutionToken` is a compile-time opaque type and a frozen, property-free object at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` requires the caller's `arguments` to be losslessly JSON-serializable, checks again after cloning to contain unstable accessors, assigns a fresh token, and deep-freezes the detached arguments. A cloneable mutable exotic such as `Map` is rejected and normalized to an error before policy. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are non-writable throughout all waterfalls, so a listener cannot change which capability or scope was authorized or reach a live enclosing execution; an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers, where the execution remains usable as a `WeakMap` key without mutation races. `ToolExecutionToken` is a compile-time opaque type and a frozen, property-free object at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` reads each caller-owned field once, materializes `arguments` as detached lossless JSON in one recursive pass, assigns a fresh token, and deep-freezes the accepted arguments. A mutable exotic such as `Map` is rejected and normalized to an error before policy; one-pass materialization prevents a stateful getter from supplying different values to validation and storage. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are non-writable throughout all waterfalls, so a listener cannot change which capability or scope was authorized or reach a live enclosing execution; an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers, where the execution remains usable as a `WeakMap` key without mutation races.
A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it.

View File

@@ -28,14 +28,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:130`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:132`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:136`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:115`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:75`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:76`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:87`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:101`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:102`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:45`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:46`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:55`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:56`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |

View File

@@ -120,17 +120,13 @@ flowchart TD
pkg_session --> pkg_brand pkg_session --> pkg_brand
pkg_session --> pkg_llm pkg_session --> pkg_llm
pkg_session --> pkg_scope pkg_session --> pkg_scope
pkg_system_prompt --> pkg_llm
pkg_system_prompt --> pkg_scope
pkg_fs --> pkg_brand pkg_fs --> pkg_brand
pkg_fs --> pkg_llm pkg_fs --> pkg_llm
pkg_web --> pkg_llm pkg_web --> pkg_llm
pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_llm
pkg_agent --> pkg_brand pkg_system_prompt --> pkg_llm
pkg_agent --> pkg_llm pkg_system_prompt --> pkg_scope
pkg_agent --> pkg_scope pkg_system_prompt --> pkg_session
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
pkg_bash --> pkg_brand pkg_bash --> pkg_brand
pkg_bash --> pkg_sandbox pkg_bash --> pkg_sandbox
pkg_bash --> pkg_session pkg_bash --> pkg_session
@@ -150,18 +146,26 @@ flowchart TD
pkg_llm_replay --> pkg_session pkg_llm_replay --> pkg_session
pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_sandbox
pkg_agent --> pkg_brand
pkg_agent --> pkg_llm
pkg_agent --> pkg_scope
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_bash
pkg_bash_local --> pkg_timeout pkg_bash_local --> pkg_timeout
pkg_compact_basic --> pkg_agent
pkg_compact_basic --> pkg_compact
pkg_compact_basic --> pkg_llm
pkg_compact_basic --> pkg_session
pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_bash
pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_session
pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session
pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_jsonl --> pkg_session_persistence
pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session
pkg_session_persistence_sqlite --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_session_persistence
pkg_bash_sandbox --> pkg_bash
pkg_bash_sandbox --> pkg_bash_local
pkg_bash_sandbox --> pkg_sandbox
pkg_compact_basic --> pkg_agent
pkg_compact_basic --> pkg_compact
pkg_compact_basic --> pkg_llm
pkg_compact_basic --> pkg_session
pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_agent
pkg_user_approval --> pkg_brand pkg_user_approval --> pkg_brand
pkg_user_approval --> pkg_llm pkg_user_approval --> pkg_llm
@@ -180,9 +184,6 @@ flowchart TD
pkg_tools --> pkg_session pkg_tools --> pkg_session
pkg_tools --> pkg_system_prompt pkg_tools --> pkg_system_prompt
pkg_tools --> pkg_user_approval pkg_tools --> pkg_user_approval
pkg_bash_sandbox --> pkg_bash
pkg_bash_sandbox --> pkg_bash_local
pkg_bash_sandbox --> pkg_sandbox
pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_llm
pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_scope
@@ -209,6 +210,7 @@ flowchart TD
pkg_subagent --> pkg_agent pkg_subagent --> pkg_agent
pkg_subagent --> pkg_llm pkg_subagent --> pkg_llm
pkg_subagent --> pkg_scope pkg_subagent --> pkg_scope
pkg_subagent --> pkg_session
pkg_subagent --> pkg_tools pkg_subagent --> pkg_tools
pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_llm
pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_system_prompt
@@ -289,6 +291,7 @@ flowchart TD
pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_agent
pkg_workflow_workerthread --> pkg_brand pkg_workflow_workerthread --> pkg_brand
pkg_workflow_workerthread --> pkg_llm pkg_workflow_workerthread --> pkg_llm
pkg_workflow_workerthread --> pkg_session
pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_subagent
pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_tools
pkg_workflow_workerthread --> pkg_workflow pkg_workflow_workerthread --> pkg_workflow
@@ -330,11 +333,10 @@ flowchart TD
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) |
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
@@ -347,21 +349,22 @@ flowchart TD
| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
@@ -378,7 +381,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |

View File

@@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity.
Types: [StreamChunk](core-data-structures/llm-streaming.md) Types: [StreamChunk](core-data-structures/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
#### `assistant/message` — surface #### `assistant/message` — surface
@@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts)
### `bash/*` ### `bash/*`
@@ -129,7 +129,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts)
### `hook/*` ### `hook/*`
@@ -165,7 +165,7 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts)
### `request/*` ### `request/*`
@@ -177,7 +177,7 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque
'request/header': { header: EpochHeader; reason: RequestHeaderReason } 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
``` ```
Source: [`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:369`](../packages/core/session/src/types.ts)
#### `request/header-delta` — log-only #### `request/header-delta` — log-only
@@ -187,7 +187,7 @@ Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
``` ```
Source: [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts)
### `steering/*` ### `steering/*`
@@ -201,7 +201,7 @@ Steering content injected between steps of a running turn.
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
### `step/*` ### `step/*`
@@ -213,7 +213,7 @@ Closes step `step` of turn `turn`.
'step/end': { turn: number; step: number } 'step/end': { turn: number; step: number }
``` ```
Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts)
#### `step/start` — log-only #### `step/start` — log-only
@@ -223,7 +223,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it
'step/start': { turn: number; step: number } 'step/start': { turn: number; step: number }
``` ```
Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
### `todo/*` ### `todo/*`
@@ -239,7 +239,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess
Types: [TodoItem](core-data-structures/session.md) Types: [TodoItem](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts)
### `tool/*` ### `tool/*`
@@ -253,7 +253,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st
Types: [CallId](core-data-structures/core.md) Types: [CallId](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts)
#### `tool/code-dispatch` — log-only #### `tool/code-dispatch` — log-only
@@ -277,7 +277,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta
Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts)
### `turn/*` ### `turn/*`
@@ -291,7 +291,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai
Types: [TurnEndReason](core-data-structures/session.md) Types: [TurnEndReason](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts)
#### `turn/start` — log-only #### `turn/start` — log-only
@@ -303,7 +303,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch
Types: [TurnTrigger](core-data-structures/session.md) Types: [TurnTrigger](core-data-structures/session.md)
Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts)
### `user/*` ### `user/*`
@@ -317,4 +317,4 @@ A user-visible prompt (queued message drained at turn start).
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts)

View File

@@ -101,7 +101,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|---|---| |---|---|
| [Provider-neutral content-block vocabulary owned by dsh-llm](implemented/architecture/2026-06-11-content-block-vocabulary.md) | 2026-06-11 | | [Provider-neutral content-block vocabulary owned by dsh-llm](implemented/architecture/2026-06-11-content-block-vocabulary.md) | 2026-06-11 |
| [Custom typed tool-schema DSL instead of schemastery](implemented/architecture/2026-06-11-custom-schema-dsl.md) | 2026-06-11 | | [Custom typed tool-schema DSL instead of schemastery](implemented/architecture/2026-06-11-custom-schema-dsl.md) | 2026-06-11 |
| [Dev-mode invariants over compile-time deep-readonly](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 | | [Source-owned session immutability and dev-mode invariants](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 |
| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 | | [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 |
| [Microkernel — extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 | | [Microkernel — extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 |
| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 | | [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 |

View File

@@ -1,29 +1,58 @@
# RFC: Dev-mode invariants over compile-time deep-readonly # RFC: Source-owned session immutability and dev-mode invariants
Status: implemented Status: implemented
## Problem ## Problem
The session log is append-only by contract, but the types don't enforce it: `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable, and `deriveMessages()` handed the logged `content` arrays/blocks out by reference. The loop then passes those derived messages into the `agent/request` waterfall and on to adapters, where mutating the request is sanctioned — so a request middleware could reach back and rewrite history, silently breaking replay equivalence and the derived-history guarantee. Separately, the event taxonomy (turn/step nesting, seq monotonicity, tool-call/result pairing, legal status transitions) was asserted only where individual tests happened to look. The session log needs two different protections: immutable ownership of each stored fact, and checks for relationships among facts across time and service seams. Conflating them in an optional development plugin would leave production history vulnerable; trying to express both through TypeScript readonly types would not create a runtime boundary or describe relational rules.
Two ways to defend the log: make immutability part of the type (`DeepReadonly<SessionEvent>` on the way out), or catch corruption at runtime in dev. The runtime-validation proposal took the runtime route; [the deep-readonly proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) took the type route. The session log is the durable source of truth for replay, request reconstruction, persistence, and user-visible history. Code outside the session package must be able to inspect that history without retaining a reference that can rewrite it later, and inputs accepted from callers must not remain connected to caller-owned mutable objects.
Immutability of individual values is only half of the contract. A log can contain perfectly immutable records whose sequence, turn/step nesting, tool-call pairing, scoped delivery, or reconstructed model request is wrong. Those rules relate multiple records or services and cannot be established by freezing one object.
TypeScript readonly types are not a sufficient runtime boundary. They disappear when the program runs, a cast can bypass them, and a recursive `DeepReadonly<T>` would spread through every log and message consumer even though some downstream request-processing APIs intentionally work with mutable values.
## Decision ## Decision
Reject the pervasive `DeepReadonly<T>` type flip. Instead: Responsibility is split between an always-on storage boundary and optional development assertions.
1. **Always-on:** `deriveMessages()` deep-clones the content it emits (one `structuredClone` per derived message). In-flight mutation of a request can no longer reach the log — this is the real fix, and it costs nothing meaningful next to a model call. ### Session owns immutable history
2. **Dev-mode:** a new `dsh-invariants` plugin (pure listeners, off in production, on in tests and demos) asserts the event contract and `Object.freeze`s logged event data so any *other* code that mutates a logged event throws instead of corrupting silently. Seeded sessions are frozen and checked on `session/created` (the constructor copies the seed without emitting `session/event`).
The invariants encode the *real* contract, not an idealized one: a `tool/call` may have no `tool/result` (a thrown tool-execution pipeline step ends the turn), and both `idle→disposed` and `running→disposed` are legal. `Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references.
The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, `session/event` observers receive the same record, and `session.events` returns a frozen array snapshot. A previously returned array does not grow after a later append. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered.
### Derived requests remain detached
`deriveMessages()` projects logged surface events into detached, deep-frozen `Message` objects and returns a fresh array snapshot. Request assembly can therefore combine derived history with other inputs without exposing a path back into the log. The cache reuses safe immutable projections rather than recloning the complete history for each model call.
### The invariants plugin checks relationships
`dsh-invariants` is a pure-listener development plugin. It does not freeze records and has no configuration; disposal removes only its assertions. It checks rules that require trace state or observation of another seam, including monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix.
When the plugin attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. This makes hot reload safe in the middle of a turn without giving the plugin ownership of session storage.
## Alternatives considered ## Alternatives considered
**The pervasive `DeepReadonly<T>` type flip** ([the rejected proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)) — compile-time only (a plugin casts straight through it), high type-noise across every log/message consumer and adapter, and it would force readonly types through code where mutation is the sanctioned API. The clone draws the mutable/immutable boundary exactly at "logged vs in-flight" without any of that noise. ### Pervasive deep-readonly types
[The rejected immutable-public-surfaces proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) would apply a recursive readonly type across public log and message surfaces. That provides editor feedback but not a runtime guarantee: TypeScript types are erased and plugin code can cast through them. It also pushes readonly types into consumers where mutation is intentional. Runtime ownership at the `Session` boundary protects every caller without that type propagation.
### Development-only freezing
Freezing history only when an invariants plugin is installed would make the core guarantee composition-dependent. Code could pass development tests and still corrupt history in production or in a focused composition that omits the plugin. Storage immutability is therefore always on, while the more expensive relational checks remain opt-in development support.
### Clone only when deriving messages
Detaching `deriveMessages()` would protect the most common request path but leave other readers of `session.events`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute.
## Consequences ## Consequences
- History corruption is caught loudly in tests and demos, at zero production cost and zero type noise. The trade-off is that the guarantee is dynamic (a dev-mode tripwire) rather than static. - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it.
- The invariants plugin doubles as executable documentation of the event taxonomy — the assertions are the contract. - `session.events` exposes stable immutable snapshots instead of the private growing array.
- `Session.events` keeps its `readonly SessionEvent[]` type; no consumer churn. - Request-side mutation cannot reach stored history through derived messages.
- This folds in [the deep-readonly proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) — there is no separate deep-readonly record; this records the decision to *not* pursue that approach. `InvariantError` is a plain `Error` with a `code` for now; a later taxonomy change can promote it. - Development builds can enable relational assertions without changing storage behavior, and disposing or omitting the plugin does not weaken log immutability.
- `dsh-invariants` has no `Config` surface because it has no behavior to tune.
- The runtime boundary carries a recursive snapshot-and-freeze cost once per accepted event; later readers and cached projections reuse the owned immutable records.

View File

@@ -149,18 +149,23 @@ Calling a service through `agent.ctx` does not implicitly make every later read
The tool view must not change because a caller kept the object it passed to `register()` or received a definition from `get()` or `visible()`. Registration therefore creates the stored identity once; future changes happen through explicit unregister/register effects. The tool view must not change because a caller kept the object it passed to `register()` or received a definition from `get()` or `visible()`. Registration therefore creates the stored identity once; future changes happen through explicit unregister/register effects.
Tool parameters cross the model and log boundary, so the registry requires them to be lossless JSON before cloning and validates the clone again to contain unstable getters. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections. Tool parameters cross the model and log boundary, so the registry materializes them with `snapshotJsonValue`: one recursive traversal reads each property once, rejects anything outside lossless JSON, and constructs the detached value that is actually stored. A check followed by `structuredClone` is not equivalent—a getter could return plain JSON to the check and a class instance to the clone, which would erase its prototype and silently accept different data. The first-party `defineTool()` helper closes the earlier authoring boundary with the same primitive: it reads every top-level option once, materializes the `SchemaSpec`, and derives an independent wire schema plus all later execute/presentation validation from that accepted snapshot. Without that split, mutating an author-owned spec after definition could make the model call a schema that the tool no longer accepts. Registration then reads every top-level definition field exactly once, validates, binds, and stores only those captured values; a stateful `parameters` or callback accessor therefore cannot make the checked definition differ from the executable one. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections.
```text ```text
defineTool(options):
accepted = read each top-level option exactly once
parameterSpec = snapshotLosslessJson(accepted.parameters)
wireParameters = snapshotLosslessJson(convertToJsonSchema(parameterSpec))
build execute and presentation validators over parameterSpec
registerTool(context, definition): registerTool(context, definition):
require definition.parameters is lossless JSON accepted = read each top-level definition field exactly once
parameters = clone(definition.parameters) parameters = snapshotLosslessJson(accepted.parameters)
require parameters is still lossless JSON
stored = deepFreeze({ stored = deepFreeze({
copied name, description, timeout, accepted name, description, timeout,
parameters, parameters,
execute: bind definition.execute to definition, execute: bind accepted.execute to definition,
presentation callbacks: bind once when present presentation callbacks: bind once when present
}) })
@@ -173,7 +178,7 @@ The reserved Code Mode transport uses the same frozen-definition contract even t
A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are explicit grants. Multiple restrictions intersect, so separately installed policies can only reduce the global surface. A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are explicit grants. Multiple restrictions intersect, so separately installed policies can only reduce the global surface.
The restriction snapshots its input, rejects an empty filter, and validates named tools against the pre-restriction capability universe. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation. The restriction reads `allow` and `deny` once, snapshots those exact values, rejects an empty filter, and validates named tools against the pre-restriction capability universe. The same captured arrays are then enforced, so a stateful accessor cannot pass one policy through validation and install another. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation.
[Code Mode](../feature/2026-06-15-code-mode.md)'s `run_code` is not an end capability. It is a reserved presentation transport that carries calls to the visible end capabilities, so the registry keeps it outside both global and scoped registration layers: restrictions cannot remove it, a scoped tool cannot shadow it, and configuration cannot explicitly allow or deny it. Without this exception, a restriction could leave the generated SDK in the prompt but remove the only way to invoke it. [Code Mode](../feature/2026-06-15-code-mode.md)'s `run_code` is not an end capability. It is a reserved presentation transport that carries calls to the visible end capabilities, so the registry keeps it outside both global and scoped registration layers: restrictions cannot remove it, a scoped tool cannot shadow it, and configuration cannot explicitly allow or deny it. Without this exception, a restriction could leave the generated SDK in the prompt but remove the only way to invoke it.
@@ -241,7 +246,9 @@ An agent's scope, session, registry entry, and driver form one owned transaction
Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup. Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup.
The factory captures IDs and the setup callback and clones caller-owned agent options, session metadata, and seed events before the first asynchronous boundary. Resume does the same before persistence loading. A caller mutating its options object later therefore cannot move the transaction away from the identities it reserved or change the configuration eventually published. The factory captures IDs and the setup callback and clones caller-owned agent options before the first asynchronous boundary. Seed events and session metadata take a stricter route: pre-cloning either could erase a class or exotic prototype before the session validator saw it, so the factory reads each reference once and hands it synchronously to `SessionStore.prepare`. That boundary rejects exotic shells, reads each accepted metadata field once, and recursively validates and copies every seed value in one pass. One-pass materialization matters because `validate(value); structuredClone(value); validate(clone)` still reads a getter twice, and the clone can erase the prototype of a class instance returned only on the second read. The accepted metadata becomes a detached, deep-frozen `SessionHeader` whose id must equal the session id. Resume applies the same rule after persistence loading by capturing `createdAt`, `cwd`, `parentSession`, and `seedLength` once before reconstruction. A caller or stateful backend therefore cannot move the transaction away from the identities it reserved, change persistence routing or lineage after publication, or sanitize invalid data into acceptance.
The session log also closes the ownership boundary after acceptance. Seed and append paths share exact runtime surface-metadata checks: surface events require either `'append'` or an exact replace record with non-negative safe-integer bounds, provenance is an array of non-negative safe integers, and non-surface events reject both fields. Accepted events are deep-frozen, and `session.events` returns a cached frozen array snapshot rather than the mutable internal array. A later append invalidates the cache and publishes a new snapshot; any earlier snapshot remains unchanged. This preserves append-only behavior even for JavaScript callers that cast away TypeScript's readonly view or retain an event reference received from `append` or `session/event`.
Reservations prevent two concurrent transactions from composing different unpublished agents under the same public identity. They remain held across persistence loading and setup and are released on every success or failure path. Reservations prevent two concurrent transactions from composing different unpublished agents under the same public identity. They remain held across persistence loading and setup and are released on every success or failure path.
@@ -356,9 +363,9 @@ Cooperative waterfalls remain the general extension mechanism, but an invariant
### Prompt protection restores named canonical contributions ### Prompt protection restores named canonical contributions
`systemPrompt.protect({ sections, tools })` declares that selected names must match the canonical registry/provider assembly after the complete `system-prompt/assemble` waterfall. Protections registered globally and for the current scope compose by set union, so callback order cannot weaken them. Protection finalizes a returned assembly rather than recovering from listener failure; if the waterfall throws, assembly still fails. `systemPrompt.protect({ sections, tools })` declares that selected names must match the canonical registry/provider assembly after the complete `system-prompt/assemble` waterfall. It reads each caller array once before deduplication, so the names checked for an empty protection are the names actually installed. Protections registered globally and for the current scope compose by set union, so callback order cannot weaken them. Protection finalizes a returned assembly rather than recovering from listener failure; if the waterfall throws, assembly still fails.
For each protected name, the service restores the canonical presence and definition. If the canonical assembly omitted the name, protection removes a listener-fabricated entry; this makes mode-dependent absence enforceable as well as presence. For each protected name, the service restores the canonical presence and definition. If the canonical assembly omitted the name, protection removes a listener-fabricated entry; this makes mode-dependent absence enforceable as well as presence. Tool providers receive the same coherence treatment: assembly reads `schemas`, optional `knownNames`, and every schema field once, detaches that record, and uses its captured names for both `toolOrder` validation and the model-visible collection. A stateful provider therefore cannot validate a phantom name while showing a different tool.
A global section protection also reserves the registry name against scoped shadowing. Registering a scoped section under an already protected global name throws, and adding global protection throws if any scoped shadow already exists. Section registration copies `name`, `order`, and the text value or callback before the check and stores that record, so later mutation of the caller's object cannot rename a safe section into a reserved one. This check must happen before assembly: otherwise the ordinary scoped-over-global merge would make the shadow itself look canonical, leaving post-waterfall restoration with the wrong owner's value. Tool-schema protection does not impose a blanket schema-name reservation because providers are additive and may deliberately contribute unrelated executable schemas. A global section protection also reserves the registry name against scoped shadowing. Registering a scoped section under an already protected global name throws, and adding global protection throws if any scoped shadow already exists. Section registration copies `name`, `order`, and the text value or callback before the check and stores that record, so later mutation of the caller's object cannot rename a safe section into a reserved one. This check must happen before assembly: otherwise the ordinary scoped-over-global merge would make the shadow itself look canonical, leaving post-waterfall restoration with the wrong owner's value. Tool-schema protection does not impose a blanket schema-name reservation because providers are additive and may deliberately contribute unrelated executable schemas.
@@ -392,7 +399,7 @@ Code Mode uses global protection for the `tools:sdk` section and reserved `run_c
### Tool executions have stable identity ### Tool executions have stable identity
`ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. The registry requires `arguments` to be losslessly JSON-serializable, validates before cloning and again after cloning to contain unstable accessors, then deep-freezes the detached value. A cloneable but mutable exotic such as `Map` is rejected before policy rather than smuggled through an apparently frozen wrapper. Invalid input still produces one normalized final error notification. `ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. It captures the required `callId`/`name` correlation identity, then reads every other top-level caller field once before using it, so parent-token validation, scope routing, policy, dispatch, and final observation all see one coherent identity; those captured optional fields construct the normalized error shell if a later accessor or argument validation fails. The registry materializes `arguments` in one lossless-JSON traversal and deep-freezes the result, so policy and dispatch receive exactly the value that passed validation. A cloneable but mutable exotic such as `Map` or a class instance is rejected before policy rather than smuggled through an apparently frozen wrapper. Invalid input still produces one normalized final error notification; a throwing `callId` or `name` accessor is outside that guarantee because no trustworthy result correlation exists.
The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution's `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field: an around-dispatch wrapper may add, replace, or remove it, and the registry freezes the complete execution before outcome observation. The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution's `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field: an around-dispatch wrapper may add, replace, or remove it, and the registry freezes the complete execution before outcome observation.
@@ -404,19 +411,18 @@ The input-to-execution conversion is intentionally one-way:
```text ```text
prepareExecution(input): prepareExecution(input):
require input.parent is absent or a registry-minted token accepted = read callId, name, arguments, agent, parent, signal exactly once
require input.arguments is lossless JSON require accepted.parent is absent or a registry-minted token
detachedArguments = clone(input.arguments) detachedArguments = snapshotLosslessJson(accepted.arguments)
require detachedArguments is still lossless JSON
execution = { execution = {
token: new frozen property-free object, token: new frozen property-free object,
callId: input.callId, callId: accepted.callId,
name: input.name, name: accepted.name,
arguments: deepFreeze(detachedArguments), arguments: deepFreeze(detachedArguments),
agent: input.agent, agent: accepted.agent,
parent: input.parent, parent: accepted.parent,
signal: input.signal signal: accepted.signal
} }
make every field except signal non-writable and non-configurable make every field except signal non-writable and non-configurable
@@ -431,7 +437,7 @@ This one-way result makes the boundary monotonic. Pre-execution hooks can still
### `tools/result` observes the authoritative live outcome ### `tools/result` observes the authoritative live outcome
The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. Immediately before that boundary, the registry validates that the entire authoritative result can round-trip losslessly through JSON; an invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log. The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. At each untrusted result boundary, the registry captures every top-level field once and materializes the complete authoritative outcome as detached lossless JSON. Immediately before observation it materializes that owned outcome again and deep-freezes the shared listener snapshot. An invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log.
Every `tools/result` listener receives the same frozen execution and deep-frozen result snapshot. Listener failures are contained independently, so they cannot change the caller's result or starve peer observers. Scope filtering derives from `execution.agent`. Every `tools/result` listener receives the same frozen execution and deep-frozen result snapshot. Listener failures are contained independently, so they cannot change the caller's result or starve peer observers. Scope filtering derives from `execution.agent`.
@@ -468,12 +474,12 @@ execute(input):
result = requireValidExecutionResult(result) result = requireValidExecutionResult(result)
result = await tools/post-execute(execution, result) result = await tools/post-execute(execution, result)
result = requireLosslessJson(result) result = snapshotLosslessJson(result)
catch pipelineFailure: catch pipelineFailure:
result = errorResult(pipelineFailure) result = errorResult(pipelineFailure)
freeze(execution) freeze(execution)
frozenResult = deepFreeze(clone(result)) frozenResult = deepFreeze(snapshotLosslessJson(result))
await every tools/result observer independently, containing each failure await every tools/result observer independently, containing each failure
return result return result
``` ```
@@ -520,11 +526,11 @@ In-process subagents demonstrate how the scope, lifecycle, and final-policy piec
Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider receiver so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and HMR cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key. Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider receiver so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and HMR cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key.
Starting a run snapshots every accepted field before asynchronous owner setup. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached. The schema is validated before cloning, while the prompt must pass the same lossless-JSON check before and after cloning that the session log requires. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. Starting a run reads every top-level request field once before capability validation, then snapshots every accepted field before asynchronous owner setup. This order makes checked and delegated capabilities identical even for a JavaScript caller with stateful accessors. Fixed scalars are checked at the same boundary: `maxDepth` must be a non-negative safe integer and `persona` must be a string. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached through the one-pass lossless-JSON materializer. The exported in-process driver repeats this boundary for direct callers before it awaits run-owner activation, including taking one seed snapshot from which it derives both the child prefix and `seedLength`. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent.
The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view.
The returned run separates acceptance from publication with `started: Promise<void>`. For spawn and fork, it fulfills only after the child factory returns a published handle, so the service can emit `subagent/start` with `ctx.agents.get(run.id)` already live; it rejects when rollback prevents publication. The service observes `result` immediately but buffers its cloned end payload until readiness, preserving start-before-end order without leaving an early rejection unhandled. A readiness rejection emits neither lifecycle event. The result driver awaits the same boundary before sending the child prompt. The provider's run separates acceptance from publication with `started: Promise<void>`, but the service does not return that caller-owned handle directly. It reads `id`, `started`, `result`, and every method once, binds methods to the original provider receiver, and returns a frozen service-owned wrapper. Its `result` promise captures `output`, optional `structured`, and `stopReason` once and resolves to one detached, deeply frozen lossless-JSON value shared by the caller and lifecycle telemetry; malformed provider data rejects as an infrastructure fault and produces contained `error` telemetry. For spawn and fork, the accepted `started` promise fulfills only after the child factory returns a published handle, so the service can emit `subagent/start` with `ctx.agents.get(id)` already live; it rejects when rollback prevents publication. The service observes the normalized result immediately but buffers its end payload until readiness, preserving start-before-end order without leaving an early rejection unhandled. Both lifecycle payloads are deeply frozen before contained per-listener dispatch, so one observer cannot corrupt the caller or a peer. A readiness rejection emits neither lifecycle event. The result driver awaits the same boundary before sending the child prompt.
```text ```text
startInProcessRun(providerContext, acceptedRequest): startInProcessRun(providerContext, acceptedRequest):
@@ -540,7 +546,7 @@ startInProcessRun(providerContext, acceptedRequest):
creation = runOwner.ctx.agents.create({ creation = runOwner.ctx.agents.create({
fresh ids and lineage, fresh ids and lineage,
cloned options and optional seed, detached options and optional seed,
setup(childCtx) => install persona, tool restriction, structured runtime setup(childCtx) => install persona, tool restriction, structured runtime
}) })
@@ -550,9 +556,16 @@ startInProcessRun(providerContext, acceptedRequest):
send the child prompt, await idle, derive the terminal result send the child prompt, await idle, derive the terminal result
SubagentService.start(...): SubagentService.start(...):
attach result settlement handlers immediately providerRun = provider.start(detached request)
await returnedRun.started serviceRun = freeze({
id, started, and methods captured once from providerRun,
methods bound to providerRun,
result: normalize once into detached, deeply frozen lossless JSON
})
attach settlement handlers to serviceRun.result immediately
await serviceRun.started
emit subagent/start; later emit the buffered or eventual subagent/end emit subagent/start; later emit the buffered or eventual subagent/end
return serviceRun
Workflow worker bridge after receiving returnedRun: Workflow worker bridge after receiving returnedRun:
register the run so cancellation can reach pre-publication work register the run so cancellation can reach pre-publication work
@@ -561,9 +574,14 @@ Workflow worker bridge after receiving returnedRun:
send ChildStarted; then send the buffered or eventual outcome send ChildStarted; then send the buffered or eventual outcome
else: else:
send ChildStartError and dispose the attempt send ChildStartError and dispose the attempt
Before publishing the workflow's own result:
abort the shared child-request signal
call cancel("workflow settled") on every host-registered run
only then settle the workflow result
``` ```
Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. This keeps cancellation able to reach pending creation, prevents an early result rejection from going unhandled, and ensures `workflow/agent-start` never names an unpublished child. Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. Before the workflow result becomes observable, the host also drives both permitted cancellation channels—the shared abort signal and each registered run's explicit `cancel()`—because a fire-and-forget child still waiting on readiness has no worker-side handle that could relay cancellation. Provider cancel callbacks are contained independently so one broken implementation cannot prevent peers from receiving cancellation or wedge the workflow result. This keeps cancellation able to reach pending creation, prevents an early result rejection from going unhandled, ensures `workflow/agent-start` never names an unpublished child, and prevents a child from publishing after its workflow has ended.
Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers. Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers.

View File

@@ -46,7 +46,7 @@ A provider exposes `start(request) → SubagentRun`. The run carries `started` (
### Fork vs. fresh are separate backends, not a flag ### Fork vs. fresh are separate backends, not a flag
Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. The fork backend seeds only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix would give the child an unbalanced turn the [invariants](../../../../packages/support/invariants/src/index.ts) freeze-check rejects. Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. The fork backend seeds only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix would give the child an unbalanced turn that the [invariants](../../../../packages/support/invariants/src/index.ts) trace replay rejects.
### Child isolation and the parent log ### Child isolation and the parent log

View File

@@ -20,13 +20,13 @@ The canonical surface separates transformable policy, around-dispatch control, a
### The tool pipeline gives each phase one kind of authority ### The tool pipeline gives each phase one kind of authority
Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guards → `tools/execute` → core dispatch → `tools/post-execute` → `tools/result`. The registry requires caller-owned `arguments` to survive lossless-JSON validation before and after cloning, then snapshots `ToolExecutionInput` into a pipeline execution with its own opaque token: identity fields and deeply frozen detached arguments are immutable for the whole pipeline, and a nested call's `parent` contains only the enclosing execution's token rather than its live object. Optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove, and the complete object freezes before final observers run. This identity contract prevents a policy listener from silently changing what the log, UI, and tool body believe ran. Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guards → `tools/execute` → core dispatch → `tools/post-execute` → `tools/result`. The registry reads each caller-owned input field once, materializes `arguments` as detached lossless JSON in one recursive pass, and snapshots `ToolExecutionInput` into a pipeline execution with its own opaque token. Identity fields and deeply frozen arguments are immutable for the whole pipeline, and a nested call's `parent` contains only the enclosing execution's token rather than its live object. Optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove, and the complete object freezes before final observers run. This identity contract prevents a policy listener from silently changing what the log, UI, and tool body believe ran.
- **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers. - **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers.
- **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids.
- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. - **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.
- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContext`; in-place mutation of the result is not a transform channel, because the registry rebuilds the outcome from a protected snapshot plus the returned decision. - **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContext`; in-place mutation of the result is not a transform channel, because the registry rebuilds the outcome from a protected snapshot plus the returned decision.
- **`tools/result`** is the awaited parallel notification after every transform, lossless-JSON validation, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome. - **`tools/result`** is the awaited parallel notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome.
Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist. Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist.
@@ -42,7 +42,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li
### Pre-tool input rewrite is a separate consistency decision ### Pre-tool input rewrite is a separate consistency decision
`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement is safe because `tool/result` is logged after execution from the final result. Input rewrite is different: `assistant/message` (model history) and `tool/call` (the audit record) are logged before `ToolRegistry.execute()`, while ACP and tool presentation read those arguments. The registry therefore seals the cloned arguments before `tools/pre-execute`; no listener or test shim can mutate them in place. An honest rewrite must update history, audit, presentation, and execution as one unit before that identity is created, which belongs to the separate [pre-tool input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) and its loop-side `TODO(pre-tool-input-rewrite)`. `PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement is safe because `tool/result` is logged after execution from the final result. Input rewrite is different: `assistant/message` (model history) and `tool/call` (the audit record) are logged before `ToolRegistry.execute()`, while ACP and tool presentation read those arguments. The registry therefore seals the materialized arguments before `tools/pre-execute`; no listener or test shim can mutate them in place. An honest rewrite must update history, audit, presentation, and execution as one unit before that identity is created, which belongs to the separate [pre-tool input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) and its loop-side `TODO(pre-tool-input-rewrite)`.
### Boundaries ### Boundaries

View File

@@ -1,25 +1,24 @@
# RFC: Deep-readonly public surfaces # RFC: Deep-readonly public surfaces
Status: rejected — the pervasive `DeepReadonly<T>` type flip was rejected in favor of an always-on `deriveMessages` clone plus dev-mode `Object.freeze` + invariants. The immutability *goal* shipped via that alternative; see [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by source-owned runtime immutability in `Session` plus relational development assertions. See [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
## Problem ## Problem
The session log is append-only by contract, but `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable: a plugin can reach in and rewrite history (`events[0].data.content.push(...)`), silently breaking replay equivalence and the derived-history guarantee. The same applies to derived messages and prompt assemblies passed through waterfalls — mutation is sometimes the intended idiom (waterfall middleware mutates the request) and sometimes corruption (mutating a *logged* event), and the types don't distinguish. The rejected proposal targeted an ownership hole that a `readonly SessionEvent[]` type alone cannot close: its elements remain mutable at runtime, so a cast or plain JavaScript can rewrite nested history. The implemented design closes that hole in `Session` by materializing and deep-freezing every accepted event and returning frozen array snapshots. In-flight prompt waterfalls remain intentionally transformable, so immutability is an ownership boundary rather than a blanket type rule.
## Proposal ## Proposal
> **Implemented differently — see the Status line and [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).** The `DeepReadonly<T>` design below was rejected as written (compile-only, high type-noise, castable). What shipped: an always-on deep clone in `deriveMessages` (closing the request/adapter aliasing path) plus a dev-mode `Object.freeze` + invariants plugin. The proposal text is kept for the record. > **Implemented differently — see the Status line and [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).** The `DeepReadonly<T>` design below is rejected as written: it is compile-only, noisy across consumers, and castable. `Session` instead snapshots and deep-freezes accepted events and public log snapshots in every composition; `deriveMessages()` returns detached frozen projections; the development plugin checks cross-record and cross-seam relationships.
Make immutability part of the type where mutation is corruption: Make immutability part of the type where mutation is corruption:
- `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session (`events`, `session/event` listeners); `append()` keeps taking plain mutable input. A `DeepReadonly<T>` utility type lands in dsh-llm next to the brand/never helpers. - `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session (`events`, `session/event` listeners); `append()` keeps taking plain mutable input. A `DeepReadonly<T>` utility type lands in dsh-llm next to the brand/never helpers.
- `deriveMessages()` returns deep-readonly messages; the loop clones before handing a mutable request to the `agent/request` waterfall (mutation there is sanctioned — the clone makes the boundary explicit and cheap, once per step). - `deriveMessages()` returns deep-readonly messages; the loop clones before handing a mutable request to the `agent/request` waterfall (mutation there is sanctioned — the clone makes the boundary explicit and cheap, once per step).
- `PromptAssembly` stays mutable through its waterfall (sanctioned) but the registry's internal section list is cloned per assembly (already true). - `PromptAssembly` stays mutable through its waterfall (sanctioned) but the registry's internal section list is cloned per assembly (already true).
- Optionally, dev-mode `Object.freeze` of event data behind [the dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) flag, so sanctioned-mutation violations throw in tests rather than corrupting silently.
## Plan ## Plan
Introduce `DeepReadonly`, flip the session read paths, fix resulting compile errors in consumers (expected: a handful in tests), add the freeze-in-dev option alongside [the dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) plugin. Introduce `DeepReadonly`, flip the session read paths, and fix the resulting compile errors in consumers.
## Risks ## Risks

View File

@@ -1700,7 +1700,7 @@ describe('BasicCompactService under the real invariants plugin', () => {
async function setup(): Promise<{ ctx: Context; session: Session; svc: BasicCompactService }> { async function setup(): Promise<{ ctx: Context; session: Session; svc: BasicCompactService }> {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
await ctx.plugin(Invariants, {}) await ctx.plugin(Invariants)
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED'))
await ctx.plugin(BasicCompactService, cfg({ auto: false })) await ctx.plugin(BasicCompactService, cfg({ auto: false }))

View File

@@ -76,7 +76,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
await ctx.plugin(Invariants, {}) await ctx.plugin(Invariants)
await ctx.plugin(SystemPrompt) await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)

View File

@@ -813,7 +813,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'SkillLookupOptions', name: 'SkillLookupOptions',
declaration: 'export interface SkillLookupOptions {\n cwd?: string | undefined;\n signal?: AbortSignal | undefined;\n}', declaration: 'export interface SkillLookupOptions {\n readonly cwd?: string | undefined;\n readonly signal?: AbortSignal | undefined;\n}',
}, },
{ {
name: 'SkillProvider', name: 'SkillProvider',

View File

@@ -8,13 +8,13 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API ### Public API
Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. Resume installs an owner-liveness sentinel before persistence load, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`.
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. - `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. - `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and detaches each raw value in one pass. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. - `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.

View File

@@ -178,20 +178,21 @@ export class AgentLoop extends Service implements AgentFactory {
*/ */
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> { async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
// Snapshot every caller-owned field before the first async setup boundary. // Snapshot every caller-owned field before the first async setup boundary.
// The callback itself is an identity capability; all data fields are // The callback itself is an identity capability. Agent options detach here;
// detached so caller mutation cannot drift a reserved/published identity or // seed and metadata stay raw only until sessions.prepare() synchronously
// the options the accepted agent observes. // reads, validates, and detaches them, so structuredClone cannot erase an
// exotic prototype before the session boundary sees it.
const agentId = options.agentId const agentId = options.agentId
const sessionId = options.sessionId const sessionId = options.sessionId
const setup = options.setup const setup = options.setup
const agentOptions = structuredClone(options.agentOptions ?? {}) const agentOptions = structuredClone(options.agentOptions ?? {})
const seed = options.seed === undefined ? undefined : structuredClone(options.seed) const seed = options.seed
const meta = structuredClone(options.meta ?? {}) const meta = options.meta
const release = this.reserve(agentId, sessionId) const release = this.reserve(agentId, sessionId)
try { try {
const session = this.ctx.sessions.prepare(sessionId, { const session = this.ctx.sessions.prepare(sessionId, {
...seed !== undefined ? { seed } : {}, ...seed !== undefined ? { seed } : {},
meta, ...meta !== undefined ? { meta } : {},
}) })
// A seeded (forked) create is still a fresh start, NOT a resume. // A seeded (forked) create is still a fresh start, NOT a resume.
return await this.startOwned(agentId, agentOptions, session, 'startup', setup) return await this.startOwned(agentId, agentOptions, session, 'startup', setup)
@@ -281,16 +282,23 @@ export class AgentLoop extends Service implements AgentFactory {
throw new Error(`agent "${agentId}" resume aborted: owner disposed during persistence load`) throw new Error(`agent "${agentId}" resume aborted: owner disposed during persistence load`)
}), }),
]) ])
// The backend is an async boundary too. Read each loaded header field
// once so a stateful implementation cannot pass a valid presence check
// and then substitute a different value during reconstruction.
const createdAt = meta.createdAt
const cwd = meta.cwd
const parentSession = meta.parentSession
const seedLength = meta.seedLength
// An out-of-band direct registry/session insertion can still race this // An out-of-band direct registry/session insertion can still race this
// service's reservation, so the public enter primitives re-check exact // service's reservation, so the public enter primitives re-check exact
// liveness at publication. // liveness at publication.
const session = this.ctx.sessions.prepare(sessionId, { const session = this.ctx.sessions.prepare(sessionId, {
seed: events, seed: events,
meta: { meta: {
createdAt: meta.createdAt, createdAt,
...meta.cwd !== undefined ? { cwd: meta.cwd } : {}, ...cwd !== undefined ? { cwd } : {},
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, ...parentSession !== undefined ? { parentSession } : {},
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {}, ...seedLength !== undefined ? { seedLength } : {},
}, },
}) })
// Calling startOwned synchronously installs the complete lifecycle // Calling startOwned synchronously installs the complete lifecycle

View File

@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm' import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
@@ -99,6 +99,22 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('createAgent sends raw metadata to the session validator before cloning can sanitize it', async () => {
class ExoticMeta {
readonly cwd = '/accepted'
}
const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')]))
await expect(ctx.agents.create({
agentId: AgentId('exotic-meta-agent'),
sessionId: SessionId('exotic-meta-session'),
meta: new ExoticMeta(),
})).rejects.toThrow(/session metadata is not a plain JSON record/)
expect(ctx.agents.get(AgentId('exotic-meta-agent'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('exotic-meta-session'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('resume of a session with no cwd carries an undefined cwd header', async () => { it('resume of a session with no cwd carries an undefined cwd header', async () => {
// Lifecycle 1: create a no-cwd session and run a turn. // Lifecycle 1: create a no-cwd session and run a turn.
const adapter1 = new MockAdapter([textResponse('a')]) const adapter1 = new MockAdapter([textResponse('a')])
@@ -411,6 +427,54 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.fiber.dispose() await ctx2.fiber.dispose()
}) })
it('reads each loaded metadata field once before reconstructing a resumed session', async () => {
const sessionId = SessionId('resume-loaded-meta-once')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const loaded = await ctx.sessionPersistence.load(sessionId)
const reads = { createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 }
const meta = Object.defineProperties({
version: loaded.meta.version,
id: loaded.meta.id,
}, {
createdAt: {
enumerable: true,
get: () => { reads.createdAt += 1; return reads.createdAt === 1 ? loaded.meta.createdAt : 1n },
},
cwd: {
enumerable: true,
get: () => { reads.cwd += 1; return reads.cwd === 1 ? '/loaded' : 'relative' },
},
parentSession: {
enumerable: true,
get: () => { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
},
seedLength: {
enumerable: true,
get: () => { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
},
}) as unknown as SessionHeader
ctx.sessionPersistence.load = () => Promise.resolve({ meta, events: loaded.events })
const resumed = await ctx.agents.resume({
agentId: AgentId('resume-loaded-meta-once'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
})
expect(reads).toEqual({ createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 })
expect(resumed.agent.session.header).toEqual({
version: loaded.meta.version,
id: sessionId,
createdAt: loaded.meta.createdAt,
cwd: '/loaded',
parentSession: 'parent',
seedLength: 0,
})
await resumed.dispose()
await ctx.fiber.dispose()
})
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => { it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
// Lifecycle 1: run a turn, then inject context while idle. The idle inject // Lifecycle 1: run a turn, then inject context while idle. The idle inject
// wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC) // wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)

View File

@@ -605,7 +605,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false }) await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
return ctx return ctx
} }
@@ -1014,7 +1014,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false }) await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
// Blocking listener on the parent context (survives fiber disposal). // Blocking listener on the parent context (survives fiber disposal).
@@ -1071,7 +1071,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false }) await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
@@ -1127,7 +1127,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false }) await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => { ctx.on('agent/pre-step', async () => {
@@ -1179,7 +1179,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false }) await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => { ctx.on('agent/pre-step', async () => {
@@ -1228,7 +1228,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false }) await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm' import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
@@ -307,6 +307,36 @@ describe('agent scope lifecycle', () => {
await retry.dispose() await retry.dispose()
}) })
it('rejects an exotic seed before publishing either reserved identity', async () => {
const ctx = await harness()
const published: string[] = []
ctx.on('session/created', () => { published.push('session') })
ctx.on('agent/created', () => { published.push('agent') })
class ExoticData { readonly value = 'not durable JSON' }
const seed = [{
seq: 0,
type: 'test/exotic-seed',
data: new ExoticData(),
}] as unknown as SessionEvent[]
await expect(ctx.agents.create({
agentId: AgentId('exotic-seed'),
sessionId: SessionId('exotic-seed-session'),
agentOptions: { model: 'mock' },
seed,
})).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('exotic-seed'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined()
const retry = await ctx.agents.create({
agentId: AgentId('exotic-seed'),
sessionId: SessionId('exotic-seed-session'),
agentOptions: { model: 'mock' },
})
await retry.dispose()
})
it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => { it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
const ctx = await harness() const ctx = await harness()
let boom = true let boom = true

View File

@@ -20,7 +20,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package.
- `ctx.agents.setFactory(factory: AgentFactory): () => Promise<void> | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. - `ctx.agents.setFactory(factory: AgentFactory): () => Promise<void> | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options/metadata/seed, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; setup rejection or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered. - `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; seed rejection, setup rejection, or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured. - `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.

View File

@@ -48,7 +48,10 @@ export interface CreateAgentOptions {
* `cwd`/`parentSession`/`seedLength` fields of * `cwd`/`parentSession`/`seedLength` fields of
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only * {@link CreateSessionOptions.meta} in dsh-session (the internal-only
* `createdAt`, used when reconstructing a persisted session, is deliberately * `createdAt`, used when reconstructing a persisted session, is deliberately
* excluded — a factory caller never sets it). * excluded — a factory caller never sets it). The factory reads this raw
* reference once and hands it synchronously to the session boundary, which
* rejects an exotic shell and captures each accepted field once before any
* asynchronous setup.
*/ */
meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number } meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number }
/** /**
@@ -57,9 +60,11 @@ export interface CreateAgentOptions {
* prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the
* in-process FORK subagent backend to seed a child with a balanced * in-process FORK subagent backend to seed a child with a balanced
* completed-turn prefix of the parent's log. The prefix MUST be contiguous * completed-turn prefix of the parent's log. The prefix MUST be contiguous
* from seq 0 and balanced (no open turn/step, no dangling tool-call), or the * from seq 0, carry only lossless-JSON data, and be balanced (no open
* session constructor (and the dev-mode invariants replay) reject it. Absent * turn/step, no dangling tool-call), or the session constructor (and the
* for a fresh (spawn) child. * dev-mode invariants replay) reject it. The factory passes the raw seed to
* the synchronous one-pass validator/copier; it never pre-clones and thereby
* sanitizes exotic prototypes. Absent for a fresh (spawn) child.
*/ */
seed?: SessionEvent[] seed?: SessionEvent[]
/** Per-agent options (model, …). */ /** Per-agent options (model, …). */

View File

@@ -12,7 +12,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co
- `scopeTarget(base: T, key?: ScopeKey): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). - `scopeTarget(base: T, key?: ScopeKey): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `Scoped<T>` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. - `Scoped<T>` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
- `scopeHost(ctx, services)` Test/tooling host whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`. - `scopeHost(ctx, services)` Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
## Design contract ## Design contract

View File

@@ -320,6 +320,8 @@ export interface ScopeHost {
* can never be satisfied RESOLVES its fiber await without ever running the * can never be satisfied RESOLVES its fiber await without ever running the
* callback — a silent no-op host. This helper fails LOUD instead: when the * callback — a silent no-op host. This helper fails LOUD instead: when the
* callback did not run, it names the absent services and disposes the host. * callback did not run, it names the absent services and disposes the host.
* The service list is copied before plugin activation so caller mutation
* across the await cannot change dependency resolution or diagnostics.
* @param ctx - the context to mount the host under. * @param ctx - the context to mount the host under.
* @param services - the service names scopes minted through this host reach * @param services - the service names scopes minted through this host reach
* (the host plugin's `inject` list). * (the host plugin's `inject` list).
@@ -328,16 +330,20 @@ export interface ScopeHost {
* Cordis dead end. * Cordis dead end.
*/ */
export async function scopeHost(ctx: Context, services: string[]): Promise<ScopeHost> { export async function scopeHost(ctx: Context, services: string[]): Promise<ScopeHost> {
// The inject list crosses an await before missing-service diagnostics run.
// Detach it now so caller mutation cannot change either Cordis dependency
// resolution or the names reported by this helper.
const requiredServices = [...services]
let hostCtx: Context | undefined let hostCtx: Context | undefined
// A named function statement (not Object.assign({name}) — Function.name is // A named function statement (not Object.assign({name}) — Function.name is
// read-only) so diagnostics read `scopeHost`. // read-only) so diagnostics read `scopeHost`.
function scopeHostPlugin(inner: Context): void { hostCtx = inner } function scopeHostPlugin(inner: Context): void { hostCtx = inner }
const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: services })) const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: requiredServices }))
await fiber await fiber
if (hostCtx === undefined) { if (hostCtx === undefined) {
// Dependency-pending: cordis resolves the await without running the // Dependency-pending: cordis resolves the await without running the
// callback. Name the absentees and unwind the pending fiber. // callback. Name the absentees and unwind the pending fiber.
const missing = services.filter(name => ctx.get(name) === undefined) const missing = requiredServices.filter(name => ctx.get(name) === undefined)
await fiber.dispose() await fiber.dispose()
/* v8 ignore next -- the '(unknown)' fallback is defensive: a pending /* v8 ignore next -- the '(unknown)' fallback is defensive: a pending
* fiber with zero absent services cannot occur (an all-present inject * fiber with zero absent services cannot occur (an all-present inject

View File

@@ -367,6 +367,16 @@ describe('scopeHost', () => {
.rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available') .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
}) })
it('snapshots missing-service diagnostics across the host activation await', async () => {
const ctx = new Context()
const services = ['tools', 'systemPrompt']
const pending = scopeHost(ctx, services)
services.splice(0)
await expect(pending)
.rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
})
it('names a single absent service in the singular', async () => { it('names a single absent service in the singular', async () => {
const ctx = new Context() const ctx = new Context()
await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available') await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available')

View File

@@ -8,7 +8,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API ### Public API
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. - `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log: the constructor reads each array entry once, then recursively validates and copies every nested value in one pass so validation and storage cannot observe different getter results or erase an exotic prototype before checking it. `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`: the store rejects an exotic metadata shell, reads every accepted field once, and constructs a detached, deep-frozen header. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier. - `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.get(id: SessionId): Session | undefined`
@@ -18,7 +18,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: `create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
- `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`. - `ctx.sessions.prepare(id?, options?): Session` — read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event`, capture its scope carrier, and add the session to the store; returns the idempotent DETACH disposer, which clears both notification and carrier state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session. - `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event`, capture its scope carrier, and add the session to the store; returns the idempotent DETACH disposer, which clears both notification and carrier state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session.
- `ctx.sessions.announce(session): void` — emit `session/created` for an entered session. - `ctx.sessions.announce(session): void` — emit `session/created` for an entered session.
@@ -32,12 +32,17 @@ The store announces creation, publishes each append, and provides an awaited dur
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points). A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` controls how the event enters the surface linked list, and `sourceEventSeqs` records provenance (the seq numbers of events this one derives from). It is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The marker requirement is enforced two ways: the typed overload makes `opts` mandatory when `type` is a specific `SurfaceEventType` literal, AND `append` **throws** at runtime if a surface-eligible event arrives with no `surfaceOp` — covering the case where `type` widens to the `SessionEventType` union (a caller iterating raw events, where the conditional overload collapses to optional) so a marker-less message event can never silently land in the log and vanish from `deriveMessages()`. - `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` or surface metadata is not losslessly JSON-serializable (BigInt, function, symbol, undefined, `-0`, non-finite number, circular ref, or an exotic object like Map/Set/Date/class instance). One recursive validate-and-copy pass reads each nested value exactly once and produces the detached value that enters the log, so validation and durability cannot diverge through a stateful getter or a prototype-erasing clone. The accepted event and every nested value are deep-frozen before publication; the returned event and observer notification share that immutable owned record. A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` and `sourceEventSeqs` are each read once, then the former controls how the event enters the surface linked list and the latter records provenance. Runtime validation accepts only `'append'` or the exact `{ op: 'replace', start, end }` record with non-negative safe-integer bounds, and provenance must be an array of non-negative safe integers; non-surface events reject either field. The marker is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The contract is enforced two ways: the typed overload handles a specific event literal, AND runtime checks cover widened unions and raw seed/load logs so invalid metadata can never silently enter or disappear from `deriveMessages()`.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback. - `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). - `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled. - `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled.
- `session.events`, `session.seq`, `session.id` - `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event.
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction. - `session.seq`, `session.id`
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates its lossless-JSON shape and requires the header id to match `session.id`, so a caller cannot later mutate persistence routing or lineage through an aliased header. Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
### Lossless JSON utilities
Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization.
### Surface types ### Surface types
@@ -65,12 +70,12 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Metadata types (`types.ts`) ### Metadata types (`types.ts`)
- `SessionHeader` — immutable session metadata, written once: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). - `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
### Extension points ### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through. - Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The constructor reads each seed entry once and uses the same one-pass lossless-JSON snapshot and exact surface-metadata shape checks as `append`, then enforces contiguous seqs and deep-freezes every accepted record; a stateful caller, exotic nested value, marker-less or malformed surface event, metadata on a non-surface event, or retained seed reference therefore cannot silently change the reconstructed history. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
### What is NOT here (TODO) ### What is NOT here (TODO)

View File

@@ -14,12 +14,12 @@ import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { isJsonValue } from './json.ts' import { snapshotJsonValue } from './json.ts'
import { SurfaceManager, isSurfaceEligibleType } from './surface.ts' import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
import { foldRequestHeader } from './request-header.ts' import { foldRequestHeader } from './request-header.ts'
export * from './types.ts' export * from './types.ts'
export { isJsonValue } from './json.ts' export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts' export type { JsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts' export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts' export type { SurfaceNode } from './surface.ts'
@@ -99,6 +99,160 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc
] ]
} }
/** Reject a record shell that cloning or spreading would otherwise sanitize. */
function assertPlainRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
if (value === null || typeof value !== 'object') {
throw new Error(`${label} is not a plain JSON record`)
}
const prototype = Object.getPrototypeOf(value) as unknown
if (prototype !== Object.prototype && prototype !== null) {
throw new Error(`${label} is not a plain JSON record`)
}
}
/** Capture and validate the caller-owned fields that become a session header. */
function snapshotSessionMeta(source: CreateSessionOptions['meta']): NonNullable<CreateSessionOptions['meta']> {
if (source === undefined) return {}
assertPlainRecord(source, 'session metadata')
// Read each accepted field exactly once. The metadata vocabulary is scalar,
// so this plain record is already detached from the caller; cloning the
// caller's shell first would erase a class prototype before validation.
const cwd = source.cwd
const parentSession = source.parentSession
const createdAt = source.createdAt
const seedLength = source.seedLength
const accepted = {
...cwd !== undefined ? { cwd } : {},
...parentSession !== undefined ? { parentSession } : {},
...createdAt !== undefined ? { createdAt } : {},
...seedLength !== undefined ? { seedLength } : {},
}
const snapshot = snapshotJsonValue(accepted)
if (snapshot === undefined) throw new Error('session metadata is not losslessly JSON-serializable')
if (snapshot.cwd !== undefined) {
if (typeof snapshot.cwd !== 'string') throw new Error('session cwd must be a string')
if (!isAbsolute(snapshot.cwd)) {
throw new Error(`session cwd must be an absolute path, got "${snapshot.cwd}"`)
}
}
if (snapshot.parentSession !== undefined && typeof snapshot.parentSession !== 'string') {
throw new Error('session parentSession must be a string')
}
if (snapshot.createdAt !== undefined
&& (typeof snapshot.createdAt !== 'number' || !Number.isFinite(snapshot.createdAt))) {
throw new Error('session createdAt must be a finite number')
}
if (snapshot.seedLength !== undefined
&& (typeof snapshot.seedLength !== 'number' || !Number.isSafeInteger(snapshot.seedLength) || snapshot.seedLength < 0)) {
throw new Error('session seedLength must be a non-negative safe integer')
}
return snapshot
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: SessionHeader = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
assertPlainRecord(input, 'session header')
// Capture each property once before validation. A stateful accessor therefore
// cannot present one identity or storage location to a check and publish a
// different one afterward.
const version = input.version
const headerId = input.id
const createdAt = input.createdAt
const cwd = input.cwd
const parentSession = input.parentSession
const seedLength = input.seedLength
const accepted = {
version,
id: headerId,
createdAt,
...cwd !== undefined ? { cwd } : {},
...parentSession !== undefined ? { parentSession } : {},
...seedLength !== undefined ? { seedLength } : {},
}
const snapshot = snapshotJsonValue(accepted)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
if (snapshot.version !== SESSION_FORMAT_VERSION) {
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(snapshot.version)}`)
}
if (snapshot.id !== id) {
throw new Error(`session header id "${String(snapshot.id)}" does not match session id "${id}"`)
}
if (typeof snapshot.createdAt !== 'number' || !Number.isFinite(snapshot.createdAt)) {
throw new Error('session header createdAt must be a finite number')
}
if (snapshot.cwd !== undefined) {
if (typeof snapshot.cwd !== 'string') throw new Error('session header cwd must be a string')
if (!isAbsolute(snapshot.cwd)) {
throw new Error(`session header cwd must be an absolute path, got "${snapshot.cwd}"`)
}
}
if (snapshot.parentSession !== undefined && typeof snapshot.parentSession !== 'string') {
throw new Error('session header parentSession must be a string')
}
if (snapshot.seedLength !== undefined
&& (typeof snapshot.seedLength !== 'number' || !Number.isSafeInteger(snapshot.seedLength) || snapshot.seedLength < 0)) {
throw new Error('session header seedLength must be a non-negative safe integer')
}
return deepFreeze(snapshot)
}
/** Validate the runtime shape of surface metadata after its JSON snapshot. */
function assertSurfaceMetadataShape(
type: string,
surfaceOp: unknown,
sourceEventSeqs: unknown,
): void {
const eligible = isSurfaceEligibleType(type)
if (!eligible) {
if (surfaceOp !== undefined || sourceEventSeqs !== undefined) {
throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`)
}
return
}
if (surfaceOp === undefined) {
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
}
if (surfaceOp !== 'append') {
if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
throw new Error(`session event "${type}" carries an invalid surfaceOp`)
}
const op = surfaceOp as Record<string, unknown>
const keys = Object.keys(op)
if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
|| op['op'] !== 'replace'
|| typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
|| typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
throw new Error(`session event "${type}" carries an invalid replace surfaceOp`)
}
}
if (sourceEventSeqs !== undefined) {
if (!Array.isArray(sourceEventSeqs)
|| sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`)
}
}
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
const event = value
const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
if (Object.keys(event).some(key => !allowed.has(key))
|| !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
|| !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number'
|| !Number.isSafeInteger(event['seq']) || event['seq'] < 0
|| !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number'
|| !Number.isSafeInteger(event['time']) || event['time'] < 0
|| !Object.hasOwn(event, 'data')) {
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
}
/** /**
* An event-sourced session: an append-only log of {@link SessionEvent}s. * An event-sourced session: an append-only log of {@link SessionEvent}s.
* *
@@ -126,10 +280,10 @@ export class Session {
} }
/** /**
* Immutable creation metadata (format version, cwd, lineage, seed boundary). * Detached, deep-frozen creation metadata (format version, cwd, lineage,
* Supplied by the store via `ctx.sessions.create()`. When a `Session` is * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
* constructed bare (tests, ad-hoc replay), a minimal header is synthesized * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
* (stamped with the current {@link SESSION_FORMAT_VERSION}) so * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* `session.header` is always present. Kept out of the event log — it is a * `session.header` is always present. Kept out of the event log — it is a
* storage concern, not replayable conversation state. * storage concern, not replayable conversation state.
*/ */
@@ -144,12 +298,27 @@ export class Session {
// `seq = log.length` contract the whole system relies on). Without this, // `seq = log.length` contract the whole system relies on). Without this,
// a bad seed would surface only later as a backend rejection or a silent // a bad seed would surface only later as a backend rejection or a silent
// divergence between the live log and disk. // divergence between the live log and disk.
seed.forEach((event, index) => { this.log = Array.from(seed, (source, index) => {
if (event.seq !== index) { // Spreading would erase a class instance's prototype. Reject an exotic
throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`) // event shell before that normalization can turn it into an apparently
// valid plain record; field values are still captured by the one spread
// below, so their accessors are not read twice.
assertPlainRecord(source, `seed event at index ${index}`)
// Read every enumerable event field once. Validation and snapshot
// construction must consume this same captured record: a stateful seed
// index or event getter cannot present one record to the checks and
// another to the durable log.
const event = { ...source }
// Materialize the complete accepted record in one recursive pass. A
// validate-then-structuredClone sequence would reread nested getters and
// could sanitize a class instance returned only to the clone.
const snapshot = snapshotJsonValue(event)
if (snapshot === undefined) {
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
} }
if (!isJsonValue(event.data)) { assertSessionEventEnvelope(snapshot, index)
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`) if (snapshot.seq !== index) {
throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
} }
// Surface-eligible events MUST carry a surfaceOp marker — the surface is // Surface-eligible events MUST carry a surfaceOp marker — the surface is
// the sole source of derived history, so a marker-less message event // the sole source of derived history, so a marker-less message event
@@ -157,30 +326,30 @@ export class Session {
// this at compile time via its typed overload; a seed arrives as raw // this at compile time via its typed overload; a seed arrives as raw
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at // SessionEvent[] (replay/fork/load), bypassing that, so re-check at
// runtime here rather than silently resuming with empty history. // runtime here rather than silently resuming with empty history.
if (isSurfaceEligibleType(event.type) const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
&& (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) { try {
throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`) assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs)
} catch (error: unknown) {
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
} }
return deepFreeze(snapshot)
}) })
// Deep-clone each seed event, NOT just the array: the seed events and
// their `data` are still owned by the caller (or the source session of a
// fork), so keeping the references would let a post-create mutation of the
// original rewrite this session's durable log — or reintroduce a
// non-JSON-serializable value AFTER the validation above. Snapshotting at
// the boundary makes `session.events` independent and keeps it equal to
// what was validated. Serializability is guaranteed by the check above, so
// structuredClone can never hit a non-cloneable value here.
this.log = seed.map(event => structuredClone(event))
} }
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } this.header = snapshotSessionHeader(id, header)
} }
/** Cached immutable public snapshot of the private append-only log. */
private eventsSnapshot: readonly SessionEvent[] | undefined
/** /**
* The append-only event log, exposed live by reference (readonly-typed, not * An immutable snapshot of the append-only event log. The snapshot is reused
* a snapshot): later appends are visible through the same array. * until the next append; a previously returned array does not grow later.
* Events and their nested data are deep-frozen at acceptance, so neither a
* cast nor ordinary JavaScript can rewrite durable history.
*/ */
get events(): readonly SessionEvent[] { get events(): readonly SessionEvent[] {
return this.log this.eventsSnapshot ??= Object.freeze([...this.log])
return this.eventsSnapshot
} }
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
@@ -205,23 +374,27 @@ export class Session {
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
* `data` that entered the log, so reading `event.data` back sees the logged * `data` that entered the log, so reading `event.data` back sees the logged
* value, never the caller's still-mutable input. * value, never the caller's still-mutable input.
* @throws if `data` is not losslessly JSON-serializable (BigInt, function, * @throws if `type` is not a string, or if `data` or surface metadata is not
* symbol, undefined, non-finite number, circular ref, or an exotic object * losslessly JSON-serializable
* like Map/Set/Date). The event log is the durable source of truth, so this * (BigInt, function, symbol, undefined, negative zero, non-finite number,
* invariant is enforced at the source — a bad event never enters the log, * circular reference, sparse array, or an exotic object such as
* keeping `session.events` always equal to what a backend can persist. The * Map/Set/Date/class instance). One recursive pass reads, validates, and
* throw surfaces at the buggy caller's append site, not asynchronously in a * copies each nested value once, so a stateful getter cannot supply one value
* backend flush. * to validation and another to storage. The event log is the durable source
* of truth, so a bad event fails at the append site rather than later during
* a backend flush.
*/ */
append<T extends SessionEventType>( append<T extends SessionEventType>(
type: T, type: T,
data: SessionEventMap[T], data: SessionEventMap[T],
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent<T> { ): SessionEvent<T> {
if (!isJsonValue(data)) { if (typeof type !== 'string') {
throw new Error(`session event "${type}" carries non-JSON-serializable data`) throw new TypeError('session event type must be a string')
} }
const surfaceOpts: SurfaceIntent | undefined = opts[0] const surfaceOpts: SurfaceIntent | undefined = opts[0]
const sourceEventSeqs = surfaceOpts?.sourceEventSeqs
const surfaceOp = surfaceOpts?.surfaceOp
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the // Surface-eligible events MUST carry a surfaceOp marker — the surface is the
// sole source of derived history, so a marker-less message event would be // sole source of derived history, so a marker-less message event would be
// logged yet vanish from deriveMessages(). The typed `opts` overload makes // logged yet vanish from deriveMessages(). The typed `opts` overload makes
@@ -230,41 +403,49 @@ export class Session {
// events: `for (const e of log) append(e.type, e.data)`), the conditional // events: `for (const e of log) append(e.type, e.data)`), the conditional
// rest collapses to optional and the compiler stops enforcing it. Re-check // rest collapses to optional and the compiler stops enforcing it. Re-check
// at runtime so that loophole can't silently drop history. // at runtime so that loophole can't silently drop history.
if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) { const surfaceMetadata = {
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`) ...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {},
...surfaceOp !== undefined ? { surfaceOp } : {},
} }
// Snapshot `data` into the log, NOT the caller's reference: the validation // The caller still owns the data and metadata objects and could mutate them
// above proves it is JSON-serializable AT THIS MOMENT, but the caller still // after append. Materialize each accepted value exactly once while checking
// owns the object and could mutate it afterwards (before a persistence // its JSON vocabulary, so the log cannot drift and a stateful getter cannot
// flush, or permanently in the in-memory history) — making `session.events` // show one value to validation and another to a prototype-erasing clone. The
// diverge from the value that passed validation, or reintroducing a // returned event carries these SAME snapshots.
// non-serializable value. Cloning here keeps the log equal to what was
// validated. structuredClone is safe because serializability was just
// checked. The returned event carries the SAME snapshot, so a caller reading
// back `event.data` sees the logged value, not its own mutable input.
// //
// Surface metadata is snapshot separately: sourceEventSeqs (number[] — // Surface metadata accessors are read once into one plain record; the
// primitives, so array spread is a complete copy) and surfaceOp (a string // recursive snapshot then reads each nested value once as it copies it.
// primitive, or cloned if it's a replace object).
// Build the event shape with conditional surface fields via spreading. // Build the event shape with conditional surface fields via spreading.
// The result is cast through `unknown` because the conditional spreads // The result is cast through `unknown` because the conditional spreads
// produce an intersection type that the assignability checker can't // produce an intersection type that the assignability checker can't
// narrow to a specific discriminated-union member when T is generic. // narrow to a specific discriminated-union member when T is generic.
// This is a safe internal boundary: data was validated above, and // This is a safe internal boundary: data and surface metadata are
// surface metadata was snapshot from primitive/clone-safe values. // materialized below before the event enters the log.
const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
if (surfaceMetadataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
}
assertSurfaceMetadataShape(
type,
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
)
const event = { const event = {
type, type,
seq: this.log.length, seq: this.log.length,
time: Date.now(), time: Date.now(),
data: structuredClone(data), data: dataSnapshot,
...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {}, ...surfaceMetadataSnapshot,
...surfaceOpts?.surfaceOp !== undefined ? {
surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp),
} : {},
} as unknown as SessionEvent<T> } as unknown as SessionEvent<T>
this.log.push(event as unknown as SessionEvent) const acceptedEvent = deepFreeze(event)
this.onAppend?.(event as unknown as SessionEvent) this.log.push(acceptedEvent as unknown as SessionEvent)
return event this.eventsSnapshot = undefined
this.onAppend?.(acceptedEvent as unknown as SessionEvent)
return acceptedEvent
} }
/** Cached fold of the request-header events — see {@link requestHeader}. */ /** Cached fold of the request-header events — see {@link requestHeader}. */
@@ -457,7 +638,8 @@ export class SessionStore extends Service {
* @param id - the session id; omitted, the store mints `session-<n>`. * @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header. * @param options - seed events and/or creation metadata for the header.
* @returns the live session, already entered and announced. * @returns the live session, already entered and announced.
* @throws if a session with `id` already exists, or if `meta.cwd` is a * @throws if a session with `id` already exists, metadata is not a plain
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
* non-absolute path (storage backends key directories off it). * non-absolute path (storage backends key directories off it).
*/ */
create(id?: SessionId, options?: CreateSessionOptions): Session { create(id?: SessionId, options?: CreateSessionOptions): Session {
@@ -485,25 +667,27 @@ export class SessionStore extends Service {
* @param id - the session id; omitted, the store mints `session-<n>`. * @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header. * @param options - seed events and/or creation metadata for the header.
* @returns the constructed session, NOT yet in the store. * @returns the constructed session, NOT yet in the store.
* @throws if a session with `id` already exists, or if `meta.cwd` is a * @throws if a session with `id` already exists, metadata is not a plain
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
* non-absolute path. * non-absolute path.
*/ */
prepare(id?: SessionId, options?: CreateSessionOptions): Session { prepare(id?: SessionId, options?: CreateSessionOptions): Session {
const sessionId = SessionId(id ?? `session-${++this.counter}`) const sessionId = SessionId(id ?? `session-${++this.counter}`)
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
const cwd = options?.meta?.cwd const seed = options?.seed
if (cwd !== undefined && !isAbsolute(cwd)) { const meta = snapshotSessionMeta(options?.meta)
throw new Error(`session cwd must be an absolute path, got "${cwd}"`) const cwd = meta.cwd
} const parentSession = meta.parentSession
const seedLength = meta.seedLength
const header: SessionHeader = { const header: SessionHeader = {
version: SESSION_FORMAT_VERSION, version: SESSION_FORMAT_VERSION,
id: sessionId, id: sessionId,
createdAt: options?.meta?.createdAt ?? Date.now(), createdAt: meta.createdAt ?? Date.now(),
...cwd !== undefined ? { cwd } : {}, ...cwd !== undefined ? { cwd } : {},
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {}, ...parentSession !== undefined ? { parentSession } : {},
...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {}, ...seedLength !== undefined ? { seedLength } : {},
} }
return new Session(sessionId, options?.seed, header) return new Session(sessionId, seed, header)
} }
/** /**

View File

@@ -1,45 +1,127 @@
/** /**
* JSON-serializability validation for session event data. * Lossless-JSON validation and snapshot materialization for session data.
* *
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every * The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
* `event.data` must round-trip losslessly through JSON so any persistence * `event.data` must round-trip losslessly through JSON so any persistence
* backend can store and reload it byte-identically. This invariant belongs to * backend can store and reload it byte-identically. This invariant belongs to
* the log itself — `Session.append` enforces it at the source, so a * the log itself — `Session.append` enforces it at the source, so a
* non-serializable event never enters `session.events` and the live log can * non-serializable event never enters `session.events` and the live log can
* never diverge from what a backend can persist. Backends re-use the same * never diverge from what a backend can persist. Other public boundaries use
* predicate to validate their own `append(events)` entry point (replay/fork * {@link snapshotJsonValue} when they must validate and detach in one pass;
* paths that do not go through a live `Session`). * {@link isJsonValue} remains the non-copying structural predicate.
* *
* @module @deepseek-ai/dsh-session/json * @module @deepseek-ai/dsh-session/json
*/ */
/** /**
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite * A value that round-trips losslessly through JSON: `null`, a boolean, a finite
* number, a string, an array of such values, or a plain object whose values are * number other than negative zero, a string, an array of such values, or a
* such values. The static type companion to {@link isJsonValue} (which validates * plain object whose values are such values. TypeScript cannot distinguish
* the same shape at runtime). Use it to type a payload that must survive * `-0` from `number`, so {@link isJsonValue} and {@link snapshotJsonValue}
* session-log persistence and replay byte-identically — e.g. a tool's private * enforce that last numeric detail at runtime. Use this type for a payload that
* presentation `meta`. * must survive session-log persistence and replay byte-identically — e.g. a
* tool's private presentation `meta`.
*/ */
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
/** /**
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, * Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass.
* booleans, strings, plain arrays, and plain objects of such values. Rejects * Each array slot or own enumerable string-keyed object value is read exactly
* `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`, * once, validated, and copied immediately. This is intentionally not
* which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/ * `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter
* `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or * could return plain JSON to the check and an exotic class instance to the
* convert lossily. Sparse arrays are rejected too: a hole serializes to `null`, * clone, whose prototype `structuredClone` would erase before a later check.
* so `[1, , 3]` would not round-trip. Detects circular references (which would
* throw) and reports them as non-serializable rather than propagating the throw.
* *
* Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE * Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use
* STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and * the ordinary `Array.prototype` (subclass instances are not plain JSON
* non-enumerable properties are NOT examined, because `JSON.stringify` likewise * containers), while null-prototype objects are accepted and normalized to
* drops them — they never reach the durable form, so a non-serializable value * ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy. * numbers, unsupported scalar types, and exotic object or array shells return
* Getters are invoked during the check (again as `JSON.stringify` would), so the * `undefined`. A throwing getter is a caller failure and propagates unchanged.
* contract is for plain data records, not objects with side-effecting accessors. *
* @param value - the candidate value to validate and detach.
* @returns the detached snapshot, or `undefined` when the value is not
* losslessly JSON-serializable.
*/
export function snapshotJsonValue<T>(value: T): T | undefined {
const ancestors = new Set<object>()
const visit = (current: unknown): JsonValue | undefined => {
if (current === null) return null
switch (typeof current) {
case 'boolean':
case 'string':
return current
case 'number':
return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined
case 'bigint':
case 'function':
case 'symbol':
case 'undefined':
return undefined
case 'object':
break
}
if (ancestors.has(current)) return undefined
ancestors.add(current)
try {
if (Array.isArray(current)) {
if (Object.getPrototypeOf(current) !== Array.prototype) return undefined
const length = current.length
const snapshot: JsonValue[] = []
for (let index = 0; index < length; index++) {
if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined
const item = visit(current[index])
if (item === undefined) return undefined
snapshot.push(item)
}
return snapshot
}
const prototype = Object.getPrototypeOf(current) as unknown
if (prototype !== Object.prototype && prototype !== null) return undefined
const snapshot: { [key: string]: JsonValue } = {}
for (const key of Object.keys(current)) {
const item = visit((current as Record<string, unknown>)[key])
if (item === undefined) return undefined
// Define the key as data so a JSON field literally named "__proto__"
// cannot mutate the snapshot's prototype through ordinary assignment.
Object.defineProperty(snapshot, key, {
value: item,
enumerable: true,
configurable: true,
writable: true,
})
}
return snapshot
} finally {
ancestors.delete(current)
}
}
return visit(value) as T | undefined
}
/**
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers
* other than negative zero, booleans, strings, plain arrays, and plain objects
* of such values. Rejects `BigInt`, function, symbol, `undefined`, `-0` (which
* JSON rewrites to `0`), non-finite numbers (`NaN`/`Infinity`, which JSON turns
* into `null`), and exotic objects (`Map`/`Set`/`Date`/class instances) —
* anything `JSON.stringify` would drop, throw on, or convert lossily. Sparse
* arrays are rejected too: a hole serializes to `null`, so `[1, , 3]` would not
* round-trip. Detects circular references (which would throw) and reports them
* as non-serializable rather than propagating the throw.
*
* Scope — this is a structural plain-data predicate, not an invocation of
* `JSON.stringify`: only an object's OWN ENUMERABLE STRING-keyed properties are
* inspected (`Object.values`). Symbol-keyed and non-enumerable properties are
* omitted from the durable data surface. Custom `toJSON` behavior is not
* executed; boundaries that persist a value first materialize a new plain-data
* record with {@link snapshotJsonValue}. Getters are invoked during this check,
* so callers that need a stable detached value use that one-pass materializer
* instead of checking and then rereading a side-effecting record.
* @param value - the candidate event data to test. * @param value - the candidate event data to test.
* @param seen - objects on the current descent path, for circular-reference * @param seen - objects on the current descent path, for circular-reference
* detection; the recursion threads it — callers omit it. * detection; the recursion threads it — callers omit it.
@@ -52,7 +134,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
case 'string': case 'string':
return true return true
case 'number': case 'number':
return Number.isFinite(value) return Number.isFinite(value) && !Object.is(value, -0)
case 'bigint': case 'bigint':
case 'function': case 'function':
case 'symbol': case 'symbol':
@@ -66,6 +148,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
seen.add(value) seen.add(value)
try { try {
if (Array.isArray(value)) { if (Array.isArray(value)) {
if (Object.getPrototypeOf(value) !== Array.prototype) return false
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but // Reject sparse arrays: a hole is skipped by `every`/`forEach` but
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
// lossily. Require every index 0..length-1 to be an OWN property. // lossily. Require every index 0..length-1 to be an OWN property.

View File

@@ -32,6 +32,9 @@ export const SESSION_FORMAT_VERSION = 0
/** /**
* Immutable session metadata — written once at creation and never rewritten. * Immutable session metadata — written once at creation and never rewritten.
* {@link Session} enforces that contract at runtime: it validates and detaches
* the accepted scalar fields, requires this header's id to match the session
* id, and deep-freezes the published record.
* *
* Kept SEPARATE from the event log deliberately: format-version, cwd, and * Kept SEPARATE from the event log deliberately: format-version, cwd, and
* lineage are storage concerns, not conversation events, so they stay out of * lineage are storage concerns, not conversation events, so they stay out of
@@ -75,7 +78,8 @@ export interface CreateSessionOptions {
/** Events to seed the new session with (replay/fork). */ /** Events to seed the new session with (replay/fork). */
seed?: SessionEvent[] seed?: SessionEvent[]
/** /**
* Creation metadata. The store fills in `version`/`id` and defaults * Creation metadata. The store reads this plain record and each accepted
* field once, then fills in `version`/`id` and defaults
* `createdAt` to now; the caller supplies the storage-level fields (validated * `createdAt` to now; the caller supplies the storage-level fields (validated
* absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and
* — when reconstructing a persisted session — the original `createdAt` to * — when reconstructing a persisted session — the original `createdAt` to

View File

@@ -60,7 +60,7 @@ describe('SessionStore.fork', () => {
}) })
}) })
it('forks the latest completed boundary by default and deep-clones seed events', async () => { it('forks the latest completed boundary by default into detached frozen seed events', async () => {
const { ctx, sessions } = await setup() const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source, 1, 'hello') appendClosedTurn(source, 1, 'hello')
@@ -70,8 +70,11 @@ describe('SessionStore.fork', () => {
expect(child.events).toEqual(source.events) expect(child.events).toEqual(source.events)
expect(child.events).not.toBe(source.events) expect(child.events).not.toBe(source.events)
expect(child.events[1]).not.toBe(source.events[1]) expect(child.events[1]).not.toBe(source.events[1])
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' } expect(() => {
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
}).toThrow(TypeError)
expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
expect(firstUserMessage(child.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
expect(child.header).toMatchObject({ expect(child.header).toMatchObject({
id: SessionId('child'), id: SessionId('child'),
cwd: '/workspace', cwd: '/workspace',

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest'
import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session'
describe('snapshotJsonValue', () => {
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
const unsupportedFunction = (): void => {}
expect(snapshotJsonValue(null)).toBeNull()
expect(snapshotJsonValue(true)).toBe(true)
expect(snapshotJsonValue('text')).toBe('text')
expect(snapshotJsonValue(1.25)).toBe(1.25)
expect(snapshotJsonValue(-0)).toBeUndefined()
expect(isJsonValue(-0)).toBe(false)
expect(snapshotJsonValue(Number.NaN)).toBeUndefined()
expect(snapshotJsonValue(Number.POSITIVE_INFINITY)).toBeUndefined()
expect(snapshotJsonValue(1n)).toBeUndefined()
expect(snapshotJsonValue(unsupportedFunction)).toBeUndefined()
expect(snapshotJsonValue(Symbol('value'))).toBeUndefined()
const unsupportedUndefined: unknown = undefined
expect(snapshotJsonValue(unsupportedUndefined)).toBeUndefined()
})
it('recursively detaches dense arrays and plain or null-prototype objects', () => {
const shared = { value: 1 }
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, { shared })
const source = { list: [nullPrototype, shared], alias: shared }
const snapshot = snapshotJsonValue(source)!
shared.value = 2
expect(snapshot).toEqual({ list: [{ shared: { value: 1 } }, { value: 1 }], alias: { value: 1 } })
expect(snapshot).not.toBe(source)
expect(snapshot.list).not.toBe(source.list)
expect(snapshot.alias).not.toBe(shared)
expect(snapshot.list[0]).not.toBe(nullPrototype)
expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype)
})
it('reads each object value and array slot once while materializing', () => {
class Exotic {
readonly accepted = false
}
let objectReads = 0
let arrayReads = 0
const nested = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => {
objectReads += 1
return objectReads === 1 ? { accepted: true } : new Exotic()
},
})
const array = new Array<unknown>(1)
Object.defineProperty(array, 0, {
enumerable: true,
get: () => {
arrayReads += 1
return arrayReads === 1 ? nested : new Exotic()
},
})
expect(snapshotJsonValue(array)).toEqual([{ value: { accepted: true } }])
expect(objectReads).toBe(1)
expect(arrayReads).toBe(1)
})
it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => {
class ExoticObject {
readonly value = 1
}
class ExoticArray extends Array<number> {}
const sparse = new Array<number>(1)
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
expect(snapshotJsonValue(new ExoticObject())).toBeUndefined()
expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined()
expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined()
expect(snapshotJsonValue(sparse)).toBeUndefined()
expect(snapshotJsonValue(cyclic)).toBeUndefined()
expect(snapshotJsonValue([undefined])).toBeUndefined()
expect(snapshotJsonValue({ value: undefined })).toBeUndefined()
})
it('preserves a literal __proto__ JSON key without changing the snapshot prototype', () => {
const source = Object.create(null) as Record<string, unknown>
source.__proto__ = { safe: true }
const snapshot = snapshotJsonValue(source)!
expect(Object.getPrototypeOf(snapshot)).toBe(Object.prototype)
expect(Object.prototype.hasOwnProperty.call(snapshot, '__proto__')).toBe(true)
expect(snapshot.__proto__).toEqual({ safe: true })
})
it('propagates a throwing getter after reading it once', () => {
const failure = new Error('getter failed')
let reads = 0
const source = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => {
reads += 1
throw failure
},
})
expect(() => snapshotJsonValue(source)).toThrow(failure)
expect(reads).toBe(1)
})
})
describe('isJsonValue', () => {
it('recognizes supported scalars and rejects every lossy scalar case', () => {
const unsupportedFunction = (): void => {}
const unsupportedUndefined: unknown = undefined
expect(isJsonValue(null)).toBe(true)
expect(isJsonValue(false)).toBe(true)
expect(isJsonValue('text')).toBe(true)
expect(isJsonValue(1.25)).toBe(true)
expect(isJsonValue(-0)).toBe(false)
expect(isJsonValue(Number.NaN)).toBe(false)
expect(isJsonValue(1n)).toBe(false)
expect(isJsonValue(unsupportedFunction)).toBe(false)
expect(isJsonValue(Symbol('value'))).toBe(false)
expect(isJsonValue(unsupportedUndefined)).toBe(false)
})
it('accepts dense arrays and plain objects, including null-prototype records', () => {
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, { value: true })
expect(isJsonValue([1, { nested: null }, nullPrototype])).toBe(true)
expect(isJsonValue({ value: [1, 2] })).toBe(true)
expect(isJsonValue(nullPrototype)).toBe(true)
})
it('rejects sparse arrays, invalid children, exotic objects, and cycles', () => {
class Exotic {
readonly value = 1
}
class ExoticArray extends Array<number> {}
const sparse = new Array<number>(1)
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
expect(isJsonValue(sparse)).toBe(false)
expect(isJsonValue(new ExoticArray(1))).toBe(false)
expect(isJsonValue([undefined])).toBe(false)
expect(isJsonValue({ value: undefined })).toBe(false)
expect(isJsonValue(new Exotic())).toBe(false)
expect(isJsonValue(cyclic)).toBe(false)
})
})

View File

@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventType, TodoItem } from '@deepseek-ai/dsh-session' import type { CreateSessionOptions, SessionEventType, SessionHeader, TodoItem } from '@deepseek-ai/dsh-session'
describe('Session', () => { describe('Session', () => {
it('derives message history from the event log', () => { it('derives message history from the event log', () => {
@@ -129,6 +129,16 @@ describe('Session', () => {
expect(session.events).toHaveLength(0) expect(session.events).toHaveLength(0)
}) })
it('rejects a non-string event type without retaining or freezing caller data', () => {
const session = new Session(SessionId('invalid-event-type'))
const type = { tag: 'caller-owned' }
const appendRaw = session.append.bind(session) as unknown as (type: unknown, data: unknown) => SessionEvent
expect(() => appendRaw(type, {})).toThrow(/event type must be a string/)
expect(Object.isFrozen(type)).toBe(false)
expect(session.events).toEqual([])
})
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => { it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = new Session(SessionId('s5b')) const session = new Session(SessionId('s5b'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -156,7 +166,7 @@ describe('Session', () => {
const badSeed = [ const badSeed = [
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } }, { type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
] as unknown as SessionEvent[] ] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/non-JSON-serializable/) expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
}) })
it('validates seed events: rejects a non-contiguous seq', () => { it('validates seed events: rejects a non-contiguous seq', () => {
@@ -177,7 +187,7 @@ describe('Session', () => {
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } }, { type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[] ] as SessionEvent[]
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/surface-eligible but carries no surfaceOp/) expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
}) })
it('accepts a well-formed contiguous serializable seed', () => { it('accepts a well-formed contiguous serializable seed', () => {
@@ -190,6 +200,151 @@ describe('Session', () => {
expect(session.events).toHaveLength(3) expect(session.events).toHaveLength(3)
}) })
it('reads each seed array entry once so validation and storage use the same event', () => {
const accepted = {
type: 'turn/start' as const,
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
}
const drifted = { ...accepted, seq: 99, data: { invalid: 1n } }
let reads = 0
const seed = new Array<SessionEvent>(1)
Object.defineProperty(seed, 0, {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? accepted : drifted
},
})
const session = new Session(SessionId('seed-entry-snapshot'), seed)
expect(reads).toBe(1)
expect(session.events).toEqual([accepted])
})
it('reads a nested seed-data getter once and stores its first JSON value', () => {
let reads = 0
const data = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? 'accepted' : 1n
},
})
const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-nested-drift'), seed)
expect(reads).toBe(1)
expect(session.events[0]!.data).toEqual({ value: 'accepted' })
})
it('rejects non-JSON surface metadata in a seed event', () => {
const seed = [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp: { op: 'replace', start: 1n, end: 2 },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
it('rejects exotic seed metadata before cloning can erase its prototype', () => {
class ReplaceOp {
readonly op = 'replace' as const
readonly start = 0
readonly end = 0
}
const seed = [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp: new ReplaceOp(),
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
it('rejects an exotic seed event shell before spreading erases its prototype', () => {
class SeedEvent {
readonly type = 'turn/start' as const
readonly seq = 0
readonly time = 1
readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }
}
const seed: SessionEvent[] = [new SeedEvent()]
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
.toThrow(/not a plain JSON record/)
})
it('accepts a null-prototype seed event shell as a plain JSON record', () => {
const event = Object.assign(Object.create(null) as Record<string, unknown>, {
type: 'turn/start' as const,
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
}) as unknown as SessionEvent
const session = new Session(SessionId('seed-null-prototype'), [event])
expect(session.events).toEqual([{ ...event }])
})
it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
let reads = 0
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? 0 : 1n
},
})
const seed = [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp,
}] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-unstable-metadata'), seed)
const event = session.events[0]!
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
expect(reads).toBe(1)
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
})
it('adds seed context when surface validation throws a non-Error value', () => {
const originalHasOwn = Object.hasOwn
const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
if ((object as Record<string, unknown>)['op'] === 'replace') throw 'validator failed'
return originalHasOwn(object, property)
})
const seed = [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp: { op: 'replace', start: 0, end: 0 },
}] as unknown as SessionEvent[]
try {
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
.toThrow('invalid seed event at index 0: invalid surface metadata')
} finally {
hasOwn.mockRestore()
}
})
it('snapshots the seed: mutating the original after construction does not affect session.events', () => { it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
const seed = [ const seed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
@@ -222,6 +377,304 @@ describe('Session', () => {
// The returned event carries the same snapshot, not the caller's input. // The returned event carries the same snapshot, not the caller's input.
expect((event.data.content[0] as { text: string }).text).toBe('original') expect((event.data.content[0] as { text: string }).text).toBe('original')
}) })
it('reads a nested append-data getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-nested-drift'))
let reads = 0
const data = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? 'accepted' : 1n
},
})
const event = session.append('todo/write', data as never)
expect(reads).toBe(1)
expect(event.data).toEqual({ value: 'accepted' })
expect(session.events).toEqual([event])
})
it('reads surface metadata accessors once so a validated marker is logged', () => {
const session = new Session(SessionId('surface-intent-snapshot'))
let reads = 0
const intent = {
get surfaceOp(): 'append' | undefined {
reads += 1
return reads === 1 ? 'append' : undefined
},
}
const event = session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
intent as { surfaceOp: 'append' },
)
expect(reads).toBe(1)
expect(event.surfaceOp).toBe('append')
})
it('rejects non-JSON surface metadata before appending the event', () => {
const session = new Session(SessionId('append-bad-metadata'))
expect(() => session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
{ surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never,
)).toThrow(/non-JSON-serializable surface metadata/)
expect(session.events).toEqual([])
})
it('rejects exotic surface metadata before cloning can erase its prototype', () => {
class ReplaceOp {
readonly op = 'replace' as const
readonly start = 0
readonly end = 0
}
const session = new Session(SessionId('append-exotic-metadata'))
expect(() => session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
{ surfaceOp: new ReplaceOp() },
)).toThrow(/non-JSON-serializable surface metadata/)
expect(session.events).toEqual([])
})
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-unstable-metadata'))
let reads = 0
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? 0 : 1n
},
})
const event = session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
{ surfaceOp } as never,
)
expect(reads).toBe(1)
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
expect(session.events).toEqual([event])
})
it('rejects invalid plain surface metadata shapes at append', () => {
const session = new Session(SessionId('append-invalid-surface-shape'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
opts?: unknown,
) => SessionEvent
const data = { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }
expect(() => appendRaw('user/message', data, { surfaceOp: 'invalid' }))
.toThrow(/invalid surfaceOp/)
expect(() => appendRaw('user/message', data, {
surfaceOp: { op: 'replace', start: -1, end: 0 },
})).toThrow(/invalid replace surfaceOp/)
expect(() => appendRaw('user/message', data, {
surfaceOp: 'append',
sourceEventSeqs: [0, -1],
})).toThrow(/non-negative safe integers/)
expect(session.events).toEqual([])
})
it('rejects surface metadata on non-surface append and seed events', () => {
const session = new Session(SessionId('non-surface-metadata'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
opts?: unknown,
) => SessionEvent
expect(() => appendRaw(
'turn/start',
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
{ surfaceOp: 'append' },
)).toThrow(/not surface-eligible and cannot carry surface metadata/)
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
surfaceOp: 'append',
} as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/)
expect(session.events).toEqual([])
})
it('deep-freezes seeded and appended event snapshots', () => {
const seeded = new Session(SessionId('seed-frozen'), [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}])
const seededEvent = seeded.events[0]!
if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
expect(Object.isFrozen(seededEvent)).toBe(true)
expect(Object.isFrozen(seededEvent.data)).toBe(true)
expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
const appended = new Session(SessionId('append-frozen'))
const appendedEvent = appended.append('todo/write', {
todos: [{ content: 'first', status: 'pending' }],
})
expect(Object.isFrozen(appendedEvent)).toBe(true)
expect(Object.isFrozen(appendedEvent.data)).toBe(true)
expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true)
expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true)
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
})
it('returns cached frozen event-array snapshots that do not grow after append', () => {
const session = new Session(SessionId('events-snapshot'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const before = session.events
const beforeEvent = before[0]!
if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
expect(session.events).toBe(before)
expect(Object.isFrozen(before)).toBe(true)
expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const after = session.events
expect(before).toHaveLength(1)
expect(after).toHaveLength(2)
expect(after).not.toBe(before)
expect(session.events).toBe(after)
})
it('detaches and freezes an explicitly supplied session header', () => {
const input = {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-owned'),
createdAt: 123,
cwd: '/accepted',
parentSession: SessionId('parent'),
seedLength: 2,
}
const session = new Session(SessionId('header-owned'), undefined, input)
input.cwd = '/caller-mutated'
expect(session.header).toEqual({
version: SESSION_FORMAT_VERSION,
id: 'header-owned',
createdAt: 123,
cwd: '/accepted',
parentSession: 'parent',
seedLength: 2,
})
expect(session.header).not.toBe(input)
expect(Object.isFrozen(session.header)).toBe(true)
expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false)
expect(session.header.cwd).toBe('/accepted')
})
it('reads each supplied header field once before validation and publication', () => {
const reads = { version: 0, id: 0, createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 }
const header = {
get version() { reads.version += 1; return reads.version === 1 ? SESSION_FORMAT_VERSION : 99 },
get id() { reads.id += 1; return reads.id === 1 ? SessionId('header-once') : SessionId('drifted') },
get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN },
get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' },
get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
} as unknown as SessionHeader
const session = new Session(SessionId('header-once'), undefined, header)
expect(reads).toEqual({ version: 1, id: 1, createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 })
expect(session.header).toEqual({
version: SESSION_FORMAT_VERSION,
id: 'header-once',
createdAt: 123,
cwd: '/accepted',
parentSession: 'parent',
seedLength: 0,
})
})
it('rejects an exotic, non-JSON, or mismatched supplied header', () => {
class ExoticHeader implements SessionHeader {
readonly version = SESSION_FORMAT_VERSION
readonly id = SessionId('header-invalid')
readonly createdAt = 123
}
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
.toThrow(/not a plain JSON record/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-invalid'),
createdAt: 123,
parentSession: 1n,
} as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('other'),
createdAt: 123,
})).toThrow(/does not match session id/)
})
it('rejects invalid scalar fields in an explicitly supplied header', () => {
const base = {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-shape'),
createdAt: 123,
}
const cases: Array<{ header: unknown; error: RegExp }> = [
{ header: 1, error: /not a plain JSON record/ },
{ header: null, error: /not a plain JSON record/ },
{ header: { ...base, version: 1 }, error: /header version/ },
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ },
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
{ header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
]
for (const { header, error } of cases) {
expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
}
})
it('rejects seed records with invalid fixed-envelope fields', () => {
const base = {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}
const cases: unknown[] = [
{ ...base, extra: true },
{ ...base, type: 1 },
{ ...base, seq: '0' },
{ ...base, seq: 0.5 },
{ ...base, seq: -1 },
{ ...base, time: '1' },
{ ...base, time: 0.5 },
{ ...base, time: -1 },
{ type: base.type, seq: base.seq, time: base.time },
]
for (const [index, event] of cases.entries()) {
expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
.toThrow(/invalid event envelope/)
}
})
}) })
@@ -317,6 +770,66 @@ describe('SessionStore', () => {
}) })
}) })
it('reads session options and each metadata field once in prepare()', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const reads = { seed: 0, meta: 0, cwd: 0, parentSession: 0, createdAt: 0, seedLength: 0 }
const meta = {
get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' },
get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n },
get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN },
get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n },
}
const options = {
get seed() { reads.seed += 1; return reads.seed === 1 ? undefined : [] },
get meta() { reads.meta += 1; return reads.meta === 1 ? meta : undefined },
} as unknown as CreateSessionOptions
const session = ctx.sessions.prepare(SessionId('metadata-once'), options)
expect(reads).toEqual({ seed: 1, meta: 1, cwd: 1, parentSession: 1, createdAt: 1, seedLength: 1 })
expect(session.header).toEqual({
version: SESSION_FORMAT_VERSION,
id: 'metadata-once',
createdAt: 123,
cwd: '/accepted',
parentSession: 'parent',
seedLength: 0,
})
})
it('rejects exotic metadata before cloning can erase its prototype', async () => {
class ExoticMeta {
readonly cwd = '/accepted'
}
const ctx = new Context()
await ctx.plugin(SessionStore)
expect(() => ctx.sessions.prepare(SessionId('exotic-meta'), { meta: new ExoticMeta() }))
.toThrow(/session metadata is not a plain JSON record/)
})
it('rejects non-JSON and invalid scalar session metadata', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const cases: Array<{ meta: unknown; error: RegExp }> = [
{ meta: 1, error: /metadata is not a plain JSON record/ },
{ meta: { parentSession: 1n }, error: /metadata is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /session cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /createdAt must be a finite number/ },
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
]
for (const [index, { meta, error }] of cases.entries()) {
expect(() => ctx.sessions.prepare(SessionId(`bad-meta-${index}`), {
meta: meta as NonNullable<CreateSessionOptions['meta']>,
})).toThrow(error)
}
})
it('rejects a non-absolute meta.cwd', async () => { it('rejects a non-absolute meta.cwd', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)

View File

@@ -14,10 +14,10 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
### Public API ### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | void` Contribute a section. The registry snapshots `name`, `order`, and the text value/callback, so later caller-object mutation cannot rename a stored section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber. - `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | void` Contribute a section. The registry snapshots `name`, `order`, and the text value/callback, so later caller-object mutation cannot rename a stored section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the same captured schemas' names) is the pre-restriction universe `toolOrder` validates against. Assembly reads the result, each schema field, and the optional known-name list once before detaching them, rejects non-string schema names/descriptions or known names, and uses those same accepted strings for validation and the model-visible collection. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise<void> | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise<void> | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise<void> | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Inputs are snapshotted, empty protections throw, and disposal removes the protection. - `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise<void> | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each input array is read once and snapshotted, empty protections throw, and disposal removes the protection.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. - `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Provider output becomes one coherent detached snapshot before `toolOrder` validation. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name.
### Live events ### Live events

View File

@@ -24,6 +24,7 @@
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6" "cordis": "^4.0.0-rc.6"
}, },
"dependencies": { "dependencies": {
@@ -32,6 +33,7 @@
"devDependencies": { "devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6" "cordis": "^4.0.0-rc.6"
} }
} }

View File

@@ -18,6 +18,7 @@ import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
declare module 'cordis' { declare module 'cordis' {
interface Context { interface Context {
@@ -598,8 +599,9 @@ export class SystemPrompt extends Service {
* restored AFTER the whole waterfall, so listener registration order cannot * restored AFTER the whole waterfall, so listener registration order cannot
* strip, replace, duplicate, or fabricate it. Canonical absence is restored * strip, replace, duplicate, or fabricate it. Canonical absence is restored
* too: if the protected name is intentionally absent for an assembly, a * too: if the protected name is intentionally absent for an assembly, a
* listener-injected entry with that name is removed. The input arrays are * listener-injected entry with that name is removed. Each input array is
* snapshotted; an empty protection throws because it cannot affect output. * read once and snapshotted; an empty protection throws because it cannot
* affect output.
* Removed with the calling fiber and emits `system-prompt/change` on * Removed with the calling fiber and emits `system-prompt/change` on
* registration/unregistration. A global section protection also reserves the * registration/unregistration. A global section protection also reserves the
* name against scoped section shadows; registering protection when such a * name against scoped section shadows; registering protection when such a
@@ -609,9 +611,11 @@ export class SystemPrompt extends Service {
*/ */
protect(protection: PromptProtection): () => Promise<void> | void { protect(protection: PromptProtection): () => Promise<void> | void {
const scope = scopeOf(this.ctx) const scope = scopeOf(this.ctx)
const sections = protection.sections
const tools = protection.tools
const snapshot: PromptProtection = { const snapshot: PromptProtection = {
...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {}, ...sections !== undefined ? { sections: [...new Set(sections)] } : {},
...protection.tools !== undefined ? { tools: [...new Set(protection.tools)] } : {}, ...tools !== undefined ? { tools: [...new Set(tools)] } : {},
} }
if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) { if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) {
throw new Error('systemPrompt.protect() requires at least one section or tool name') throw new Error('systemPrompt.protect() requires at least one section or tool name')
@@ -669,6 +673,8 @@ export class SystemPrompt extends Service {
* the providers' `knownNames` universe rejects the assembly, while a known * the providers' `knownNames` universe rejects the assembly, while a known
* name restricted away for this scope is a normal absence), and every * name restricted away for this scope is a normal absence), and every
* visible variable resolved against `context` into `assembly.variables`. * visible variable resolved against `context` into `assembly.variables`.
* Each provider result and schema field is read once; those same captured
* names drive both `toolOrder` validation and the model-visible collection.
* Tool schemas are deep-cloned because adapters and request waterfalls may * Tool schemas are deep-cloned because adapters and request waterfalls may
* mutate schema objects. Runs through the `system-prompt/assemble` * mutate schema objects. Runs through the `system-prompt/assemble`
* waterfall, giving listeners the opportunity to mutate or replace the * waterfall, giving listeners the opportunity to mutate or replace the
@@ -724,12 +730,43 @@ export class SystemPrompt extends Service {
const knownNames = new Set<string>() const knownNames = new Set<string>()
for (const provider of providers) { for (const provider of providers) {
const result = provider(context) const result = provider(context)
for (const tool of result.schemas) { // One provider result snapshot: `schemas`, `knownNames`, and each schema
collected.push({ ...tool, parameters: structuredClone(tool.parameters) }) // field may be accessor-backed. The same captured names must drive both
} // toolOrder validation and the model-visible collection.
for (const name of result.knownNames ?? result.schemas.map(tool => tool.name)) { const inputSchemas = result.schemas
knownNames.add(name) const inputKnownNames = result.knownNames
const schemas = inputSchemas.map((tool, index): ToolSchema => {
const name = tool.name
const description = tool.description
const inputParameters = tool.parameters
if (typeof name !== 'string') {
throw new TypeError(`system prompt tool schema at index ${index} name must be a string`)
}
if (typeof description !== 'string') {
throw new TypeError(`system prompt tool "${name}" description must be a string`)
}
const parameters = snapshotJsonValue(inputParameters)
if (parameters === undefined) {
throw new TypeError(`system prompt tool "${name}" parameters must be losslessly JSON-serializable`)
}
return { name, description, parameters }
})
let acceptedKnownNames: string[]
if (inputKnownNames === undefined) {
acceptedKnownNames = schemas.map(tool => tool.name)
} else {
if (!Array.isArray(inputKnownNames)) {
throw new TypeError('system prompt tool provider knownNames must be an array of strings')
}
acceptedKnownNames = Array.from(inputKnownNames, (name) => {
if (typeof name !== 'string') {
throw new TypeError('system prompt tool provider knownNames must be an array of strings')
}
return name
})
} }
collected.push(...schemas)
for (const name of acceptedKnownNames) knownNames.add(name)
} }
const assembly: PromptAssembly = { const assembly: PromptAssembly = {
sections: [...sectionByName.values()] sections: [...sectionByName.values()]

View File

@@ -258,6 +258,30 @@ describe('SystemPrompt', () => {
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu']) expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
}) })
it('reads protection accessors once so the checked names are the protected names', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
let reads = 0
const protection = {
get sections(): string[] {
reads += 1
return reads === 1 ? ['protected'] : undefined as unknown as string[]
},
}
ctx.systemPrompt.protect(protection)
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'protected')
return result
})
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toBe(1)
expect(assembly.sections).toContainEqual({ name: 'protected', order: 10, text: 'canonical' })
})
it('protects canonical absence and rejects an empty protection', async () => { it('protects canonical absence and rejects an empty protection', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SystemPrompt) await ctx.plugin(SystemPrompt)

View File

@@ -48,6 +48,100 @@ describe('SystemPrompt tool order', () => {
expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash']) expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
}) })
it('reads provider schemas once so toolOrder validates the model-visible collection', async () => {
const ctx = await mount({ toolOrder: ['actual', TOOL_ORDER_REST] })
let reads = 0
ctx.systemPrompt.tools(() => ({
get schemas(): ToolSchema[] {
reads += 1
return reads === 1 ? [tool('actual')] : [tool('phantom')]
},
}))
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toBe(1)
expect(names(assembly)).toEqual(['actual'])
})
it('reads each provider schema field once before detaching it', async () => {
const ctx = await mount()
const accepted = { type: 'object', properties: { accepted: { type: 'string' } } }
let reads = 0
const schema = {
name: 'stable',
description: 'stable',
get parameters(): object {
reads += 1
return reads === 1 ? accepted : { type: 'object', properties: { drifted: { type: 'number' } } }
},
} as ToolSchema
ctx.systemPrompt.tools(() => ({ schemas: [schema] }))
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toBe(1)
expect(assembly.tools[0]?.parameters).toEqual(accepted)
})
it('rejects exotic provider parameters before model-visible assembly', async () => {
const ctx = await mount()
class ExoticParameters {
readonly type = 'object'
readonly properties = { value: { type: 'string' } }
}
ctx.systemPrompt.tools(() => ({
schemas: [{
name: 'exotic',
description: 'must not be sanitized',
parameters: new ExoticParameters() as unknown as ToolSchema['parameters'],
}],
}))
await expect(ctx.systemPrompt.assemble())
.rejects.toThrow(/parameters must be losslessly JSON-serializable/)
})
it('rejects malformed fixed provider fields without freezing caller objects', async () => {
const ctx = await mount()
const badName = { value: 'object-name' }
const badDescription = { value: 'object-description' }
ctx.systemPrompt.tools(() => ({
schemas: [{
name: badName as unknown as string,
description: 'bad name',
parameters: {},
}],
}))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('name must be a string')
expect(Object.isFrozen(badName)).toBe(false)
const descriptions = await mount()
descriptions.systemPrompt.tools(() => ({
schemas: [{
name: 'bad-description',
description: badDescription as unknown as string,
parameters: {},
}],
}))
await expect(descriptions.systemPrompt.assemble()).rejects.toThrow('description must be a string')
expect(Object.isFrozen(badDescription)).toBe(false)
const knownNames = await mount()
knownNames.systemPrompt.tools(() => ({
schemas: [tool('valid')],
knownNames: [{} as unknown as string],
}))
await expect(knownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings')
const nonArrayKnownNames = await mount()
nonArrayKnownNames.systemPrompt.tools(() => ({
schemas: [tool('valid')],
knownNames: 'valid' as unknown as string[],
}))
await expect(nonArrayKnownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings')
})
it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => { it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] }) const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] })) ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] }))

View File

@@ -22,6 +22,9 @@
}, },
{ {
"path": "../../core/scope" "path": "../../core/scope"
},
{
"path": "../../core/session"
} }
] ]
} }

View File

@@ -15,14 +15,14 @@ tools:
### Public API ### Public API
- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a tool as a frozen snapshot. Parameters must survive lossless-JSON validation before and after cloning; scalar fields are copied, and execute/presentation callbacks are bound once to the original definition as their method receiver, so later callback-property replacement cannot change dispatch. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations). - `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a tool as a frozen snapshot. Every top-level caller field is read once into one coherent acceptance record; `name`/`description` must be strings and `timeoutMs`, when present, must be positive and finite before the snapshot can own them. Parameters are validated and detached by one recursive lossless-JSON traversal, so a stateful getter cannot show one value to a check and another to a prototype-erasing clone. Execute/presentation callbacks are bound once to the original definition as their method receiver, so later caller mutation cannot change the executable definition. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations).
- `ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). - `ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The registry reads `allow`/`deny` once, so the values checked for an empty filter and unknown names are exactly the values enforced. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. Returned definitions are the registry's frozen snapshots. - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. Returned definitions are the registry's frozen snapshots.
- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` The canonical executable view — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so presentation and dispatch resolve the same frozen definitions. - `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` The canonical executable view — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so presentation and dispatch resolve the same frozen definitions.
- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`. - `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => Promise<void> | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. - `ctx.tools.guard(guard: ToolGuard): () => Promise<void> | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Snapshot one single-use call input into a pipeline-owned execution, assign its opaque correlation token, require `arguments` to be losslessly JSON-serializable before and after cloning, deep-freeze the detached arguments, and protect its identity before running `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. Validate the final result as losslessly JSON-serializable and freeze the complete execution before `tools/result` observers run. Invalid or unstable input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. - `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Read each caller-owned top-level field once, snapshot the single-use call into a pipeline-owned execution, assign its opaque correlation token, materialize `arguments` through one lossless-JSON traversal, deep-freeze them, and protect identity before running `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. After the required `callId`/`name` correlation identity is captured, the same captured optional fields build the normalized error shell if a later accessor or validation fails, so policy, dispatch, routing, and `tools/result` cannot observe different caller values. Every top-level result field is likewise captured once and the complete result or post-decision is losslessly materialized before final observation. Invalid input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. A throwing `callId` or `name` accessor rejects because no trustworthy result identity exists yet.
### Injected services ### Injected services
@@ -77,7 +77,7 @@ ctx.tools.register(defineTool({
})) }))
``` ```
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Definition is a snapshot boundary: `defineTool` reads every top-level option once, detaches the schema, and derives both an independent wire schema and every later execute/presentation validation from that accepted snapshot. Stateful accessors or later caller mutation therefore cannot make the schema shown to the model disagree with the schema enforced at runtime. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs<S>` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs<S>` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input.

View File

@@ -23,7 +23,7 @@ import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import { isJsonValue } from '@deepseek-ai/dsh-session' import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
@@ -275,10 +275,10 @@ export interface ToolExecutionInput {
/** /**
* One pending tool call inside the registry pipeline. Call identity, the * One pending tool call inside the registry pipeline. Call identity, the
* registry-assigned {@link token}, and a lossless-JSON-validated, deep-frozen * registry-assigned {@link token}, and a deep-frozen lossless-JSON snapshot of
* clone of the parsed arguments are immutable from the first policy listener onward, while an * the parsed arguments are immutable from the first policy listener onward,
* around-dispatch wrapper may set, replace, or remove only `signal`. The * while an around-dispatch wrapper may set, replace, or remove only `signal`.
* registry freezes the complete object before `tools/result` observers run. * The registry freezes the complete object before `tools/result` observers run.
*/ */
export interface ToolExecution extends ToolExecutionInput { export interface ToolExecution extends ToolExecutionInput {
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
@@ -590,12 +590,13 @@ export class ToolRegistry extends Service {
* the shadowing feature, not an error; the global-duplicate message names * the shadowing feature, not an error; the global-duplicate message names
* `agent.ctx` as the per-agent alternative), or if a non-native mode reserves * `agent.ctx` as the per-agent alternative), or if a non-native mode reserves
* the `run_code` name for its presentation transport. The visible schema set * the `run_code` name for its presentation transport. The visible schema set
* flows into prompt assembly automatically. Registration validates and * flows into prompt assembly automatically. Registration materializes the JSON
* clones the JSON parameters, copies scalar fields, binds each callback once * parameters in one pass, copies scalar fields, binds each callback once to the
* to the caller's definition as its method receiver, and freezes the stored * caller's definition as its method receiver, and freezes the stored snapshot;
* snapshot; later mutation or callback replacement on the input object does * later mutation or callback replacement on the input object does not rewrite
* not rewrite the registry. Disposed with the calling fiber. Emits * the registry. Every top-level field is read once into one coherent acceptance
* `tools/change` on register/unregister. * snapshot, so stateful accessors cannot make validation and storage use
* different values. Emits `tools/change` on register/unregister.
* @param definition - the tool's schema plus its execute (and optional * @param definition - the tool's schema plus its execute (and optional
* presentation) functions. * presentation) functions.
* @returns the disposer that unregisters the tool. The exact * @returns the disposer that unregisters the tool. The exact
@@ -604,31 +605,57 @@ export class ToolRegistry extends Service {
*/ */
register(definition: ToolDefinition): () => Promise<void> | void { register(definition: ToolDefinition): () => Promise<void> | void {
const scope = scopeOf(this.ctx) const scope = scopeOf(this.ctx)
// A schema crosses the same model/log boundary as execution arguments. // One coherent acceptance snapshot: a caller may expose fields through
// Validate BEFORE cloning because structuredClone silently turns some // accessors, so every top-level value is read exactly once before any
// forbidden values (for example class instances) into plain records, then // validation or binding. Checked parameters and stored parameters must be
// validate the detached value again to contain hostile getters that change // the same reference, and a callback cannot change between lookup/bind.
// between inspection and snapshotting. A frozen Map is still mutable, so const name = definition.name
// deepFreeze alone is not a sufficient registration boundary. const description = definition.description
if (!isJsonValue(definition.parameters)) { const inputParameters = definition.parameters
throw new TypeError('tool parameters must be losslessly JSON-serializable') const timeoutMs = definition.timeoutMs
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputExecute = definition.execute
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputPresentCall = definition.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputPresentResult = definition.presentResult
// Reject malformed fixed fields before any caller-owned value can enter the
// frozen snapshot. In particular, a boxed string/object must not become a
// Map key or get recursively frozen as though it were a scalar.
if (typeof name !== 'string') throw new TypeError('tool name must be a string')
if (typeof description !== 'string') throw new TypeError(`tool "${name}" description must be a string`)
if (timeoutMs !== undefined
&& (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
} }
const parameters = structuredClone(definition.parameters) if (typeof inputExecute !== 'function') throw new TypeError(`tool "${name}" execute must be a function`)
if (!isJsonValue(parameters)) { if (inputPresentCall !== undefined && typeof inputPresentCall !== 'function') {
throw new TypeError('tool parameters must be stable losslessly JSON-serializable data') throw new TypeError(`tool "${name}" presentCall must be a function when provided`)
}
if (inputPresentResult !== undefined && typeof inputPresentResult !== 'function') {
throw new TypeError(`tool "${name}" presentResult must be a function when provided`)
}
const execute = inputExecute.bind(definition)
const presentCall = inputPresentCall?.bind(definition)
const presentResult = inputPresentResult?.bind(definition)
// A schema crosses the same model/log boundary as execution arguments.
// Validate and detach it in one traversal: validate-then-structuredClone
// would reread getters and could erase an exotic prototype returned only to
// the clone. A frozen Map is still mutable, so deepFreeze alone is not a
// sufficient registration boundary.
const parameters = snapshotJsonValue(inputParameters)
if (parameters === undefined) {
throw new TypeError('tool parameters must be losslessly JSON-serializable')
} }
// Bind once so replacing a callback on the caller-owned definition after // Bind once so replacing a callback on the caller-owned definition after
// registration cannot change dispatch, while preserving the historical // registration cannot change dispatch, while preserving the historical
// method receiver (`this === definition`) for callbacks that use it. // method receiver (`this === definition`) for callbacks that use it.
const execute = definition.execute.bind(definition)
const presentCall = definition.presentCall?.bind(definition)
const presentResult = definition.presentResult?.bind(definition)
const snapshot: ToolDefinition = deepFreeze({ const snapshot: ToolDefinition = deepFreeze({
name: definition.name, name,
description: definition.description, description,
parameters, parameters,
execute, execute,
...definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {}, ...timeoutMs !== undefined ? { timeoutMs } : {},
...presentCall !== undefined ? { presentCall } : {}, ...presentCall !== undefined ? { presentCall } : {},
...presentResult !== undefined ? { presentResult } : {}, ...presentResult !== undefined ? { presentResult } : {},
}) })
@@ -677,8 +704,9 @@ export class ToolRegistry extends Service {
* global tools they mask exist (the agent-creation `setup` window satisfies * global tools they mask exist (the agent-creation `setup` window satisfies
* this). A non-native mode's reserved `run_code` presentation transport is * this). A non-native mode's reserved `run_code` presentation transport is
* not a filterable capability; naming it explicitly throws, while omitting * not a filterable capability; naming it explicitly throws, while omitting
* it from an allow-list cannot remove it. The filter is SNAPSHOT at * it from an allow-list cannot remove it. `allow` and `deny` are each read
* registration: later caller mutation of the arrays changes nothing. * once, then the filter is SNAPSHOT at registration: the values checked are
* the values enforced, and later caller mutation of the arrays changes nothing.
* Multiple restrictions compose by intersection. Scoped registrations * Multiple restrictions compose by intersection. Scoped registrations
* bypass restrictions (explicit grants win). Disposed with the calling * bypass restrictions (explicit grants win). Disposed with the calling
* fiber (revocable independently); emits `tools/change`. * fiber (revocable independently); emits `tools/change`.
@@ -692,13 +720,19 @@ export class ToolRegistry extends Service {
if (scope === undefined) { if (scope === undefined) {
throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead') throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead')
} }
if (filter.allow === undefined && filter.deny === undefined) { // Read each caller-owned accessor once. The same values must decide
// whether the filter is meaningful AND become the enforced snapshot: a
// stateful getter must not pass the no-op check as `allow: []` and then
// disappear when the snapshot is built.
const allow = filter.allow
const deny = filter.deny
if (allow === undefined && deny === undefined) {
throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)') throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)')
} }
// Snapshot BEFORE validation so what was checked is what is enforced. // Snapshot BEFORE validation so what was checked is what is enforced.
const snapshot: ToolRestriction = { const snapshot: ToolRestriction = {
...filter.allow !== undefined ? { allow: [...filter.allow] } : {}, ...allow !== undefined ? { allow: [...allow] } : {},
...filter.deny !== undefined ? { deny: [...filter.deny] } : {}, ...deny !== undefined ? { deny: [...deny] } : {},
} }
if (this.codeTransport !== undefined if (this.codeTransport !== undefined
&& [...snapshot.allow ?? [], ...snapshot.deny ?? []].includes(RUN_CODE_NAME)) { && [...snapshot.allow ?? [], ...snapshot.deny ?? []].includes(RUN_CODE_NAME)) {
@@ -909,35 +943,62 @@ export class ToolRegistry extends Service {
* restricted-away global is exactly as absent as a nonexistent one), the * restricted-away global is exactly as absent as a nonexistent one), the
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown * result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before * {@link HarnessError} surfaces its `{ name, code }` on the result. Before
* the final observe-only notification, the authoritative outcome must survive * the final observe-only notification, the authoritative outcome is
* a lossless JSON round trip; an invalid outcome is normalized to an error. * materialized as a detached lossless-JSON snapshot; an invalid outcome is
* normalized to an error.
* A malformed runtime/casted `tools/pre-execute` decision likewise normalizes * A malformed runtime/casted `tools/pre-execute` decision likewise normalizes
* to an error before approval, guards, or the tool body. * to an error before approval, guards, or the tool body.
* Caller-owned arguments must survive lossless-JSON validation before and * Caller-owned arguments are validated and detached in one recursive
* after cloning; a violation normalizes to an error before policy or dispatch. * lossless-JSON traversal; a violation normalizes to an error before policy
* @param exec - the single-use call input; its identity is snapshotted and * or dispatch.
* protected before policy runs. * @param exec - the single-use call input; every top-level field is read once
* @returns the final result after every waterfall; failures resolve as * and that identity snapshot is protected before policy runs (and reused by
* `isError` results, never rejections. * the normalized error shell if validation fails).
* @returns the final result after every waterfall. Once the required
* `callId` and `name` correlation identity has been captured, later
* accessor, validation, listener, and tool failures resolve as `isError`
* results rather than rejections. A throwing `callId` or `name` accessor
* rejects because no trustworthy result identity exists yet.
*/ */
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> { async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
// callId/name are the minimum correlation identity needed to construct a
// result at all. Every other caller-controlled accessor is read once
// INSIDE the normalization boundary; if one throws, the error shell uses
// the fields captured before it and never rereads the hostile record.
const callId = exec.callId
const name = exec.name
let agent: Agent | undefined
let parent: ToolExecutionToken | undefined
let signal: AbortSignal | undefined
let execution: ToolExecution let execution: ToolExecution
try { try {
execution = this.prepareExecution(exec) agent = exec.agent
parent = exec.parent
signal = exec.signal
const args = exec.arguments
const input: Readonly<ToolExecutionInput> = Object.freeze({
callId,
name,
arguments: args,
...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {},
...signal !== undefined ? { signal } : {},
})
execution = this.prepareExecution(input)
} catch (error: unknown) { } catch (error: unknown) {
// Contract-violating non-JSON or non-cloneable arguments cannot enter a // Contract-violating arguments outside the lossless-JSON vocabulary cannot
// pipeline whose logged and executed forms must agree. Still publish one // enter a pipeline whose logged and executed forms must agree. Still
// scoped final outcome, using an immutable identity shell, so result // publish one scoped final outcome, using an immutable identity shell, so
// observers retain their every-call guarantee without seeing the invalid // result observers retain their every-call guarantee without seeing the
// value. // invalid value.
execution = Object.freeze({ execution = Object.freeze({
token: createExecutionToken(), token: createExecutionToken(),
callId: exec.callId, callId,
name: exec.name, name,
arguments: undefined, arguments: undefined,
...exec.agent !== undefined ? { agent: exec.agent } : {}, ...agent !== undefined ? { agent } : {},
...isExecutionToken(exec.parent) ? { parent: exec.parent } : {}, ...isExecutionToken(parent) ? { parent } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {}, ...signal !== undefined ? { signal } : {},
}) })
const result = toolErrorResult(execution.callId, error) const result = toolErrorResult(execution.callId, error)
await this.notifyResult(execution, result) await this.notifyResult(execution, result)
@@ -945,11 +1006,11 @@ export class ToolRegistry extends Service {
} }
let result: ToolExecutionResult let result: ToolExecutionResult
try { try {
// Validate the authoritative FINAL result, not merely the tool body's // Materialize the authoritative FINAL result, not merely the tool body's
// intermediate return. Post-policy may replace content or attach context, // intermediate return. Post-policy may replace content or attach context,
// and every one of these fields is session-bound. Reject anything that // and every one of these fields is session-bound. Reject anything outside
// cannot round-trip losslessly through the durable JSON log before the // the lossless-JSON vocabulary before the observe-only `tools/result`
// observe-only `tools/result` commit point sees success. // commit point sees success.
result = this.snapshotExecutionResult(execution, await this.executePipeline(execution)) result = this.snapshotExecutionResult(execution, await this.executePipeline(execution))
} catch (error: unknown) { } catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener, guard, or the // Outer backstop: a throwing pre/post-execute listener, guard, or the
@@ -961,17 +1022,14 @@ export class ToolRegistry extends Service {
} }
/** Snapshot one call into a shared pipeline object with immutable identity and mutable cancellation. */ /** Snapshot one call into a shared pipeline object with immutable identity and mutable cancellation. */
private prepareExecution(input: ToolExecutionInput): ToolExecution { private prepareExecution(input: Readonly<ToolExecutionInput>): ToolExecution {
if (input.parent !== undefined && !isExecutionToken(input.parent)) { if (input.parent !== undefined && !isExecutionToken(input.parent)) {
throw new TypeError('tool execution parent must be a registry-minted opaque token') throw new TypeError('tool execution parent must be a registry-minted opaque token')
} }
if (!isJsonValue(input.arguments)) { const args = snapshotJsonValue(input.arguments)
if (args === undefined) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable') throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
} }
const args = structuredClone(input.arguments)
if (!isJsonValue(args)) {
throw new TypeError('tool execution arguments must be stable losslessly JSON-serializable data')
}
const execution: ToolExecution = { const execution: ToolExecution = {
token: createExecutionToken(), token: createExecutionToken(),
callId: input.callId, callId: input.callId,
@@ -1100,10 +1158,13 @@ export class ToolRegistry extends Service {
// The pipeline is over: freeze the remaining mutable signal slot so every // The pipeline is over: freeze the remaining mutable signal slot so every
// observer sees the SAME WeakMap-keyable execution without a mutation race. // observer sees the SAME WeakMap-keyable execution without a mutation race.
Object.freeze(exec) Object.freeze(exec)
// postExecute clones every accepted result/decision before rebuilding the // Materialize once more at the observer boundary so every listener receives
// outcome; all error paths construct plain data. The final result is thus // the same detached result even when an internal error path constructed it.
// structurally cloneable before it reaches this observe-only boundary. const detached = snapshotJsonValue(result)
const snapshot = deepFreeze(structuredClone(result)) if (detached === undefined) {
throw new TypeError('tool result notification must be losslessly JSON-serializable')
}
const snapshot = deepFreeze(detached)
const callbacks = this.ctx.events.dispatch('parallel', [ const callbacks = this.ctx.events.dispatch('parallel', [
scopeTarget(this, exec.agent), 'tools/result', exec, snapshot, scopeTarget(this, exec.agent), 'tools/result', exec, snapshot,
]) ])
@@ -1169,13 +1230,16 @@ export class ToolRegistry extends Service {
// authoritative-call-id requirement and the "preserve the dispatched // authoritative-call-id requirement and the "preserve the dispatched
// isError/error" contract. The decision is the ONLY sanctioned channel for a // isError/error" contract. The decision is the ONLY sanctioned channel for a
// listener to change the outcome (block, or accept-with-replacement); the // listener to change the outcome (block, or accept-with-replacement); the
// call id is always the authoritative `exec.callId`. Deep cloning protects // call id is always the authoritative `exec.callId`. The one-pass snapshot
// nested content, error, and meta data from in-place listener mutation. // protects nested content, error, and meta from in-place listener mutation.
const dispatched = this.snapshotExecutionResult(exec, result) const dispatched = this.snapshotExecutionResult(exec, result)
const decision = structuredClone(await this.ctx.waterfall( const decision = snapshotJsonValue(await this.ctx.waterfall(
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }), () => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
)) ))
if (decision === undefined) {
throw new TypeError('tools/post-execute must return a losslessly JSON-serializable decision')
}
this.assertPostDecision(decision) this.assertPostDecision(decision)
const additionalContext = decision.additionalContext const additionalContext = decision.additionalContext
if (decision.kind === 'block') { if (decision.kind === 'block') {
@@ -1200,31 +1264,36 @@ export class ToolRegistry extends Service {
throw new TypeError('tools/execute must return a ToolExecutionResult object') throw new TypeError('tools/execute must return a ToolExecutionResult object')
} }
const result = value as Partial<ToolExecutionResult> const result = value as Partial<ToolExecutionResult>
if (!Array.isArray(result.content) || typeof result.isError !== 'boolean') { // Capture the provider/listener-owned result exactly once. The same values
// must pass shape/correlation checks and become the detached final outcome;
// a stateful accessor cannot validate one result and publish another.
const callId = result.callId
const content = result.content
const isError = result.isError
const error = result.error
const additionalContext = result.additionalContext
const meta = result.meta
if (!Array.isArray(content) || typeof isError !== 'boolean') {
throw new TypeError('tools/execute must return a ToolExecutionResult with content[] and boolean isError') throw new TypeError('tools/execute must return a ToolExecutionResult with content[] and boolean isError')
} }
if (result.callId !== exec.callId) { if (callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`) throw new TypeError(`tools/execute returned callId "${String(callId)}" for authoritative call "${exec.callId}"`)
} }
const candidate = { const candidate = {
callId: exec.callId, callId: exec.callId,
content: result.content, content,
isError: result.isError, isError,
...result.error !== undefined ? { error: result.error } : {}, ...error !== undefined ? { error } : {},
...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {}, ...additionalContext !== undefined ? { additionalContext } : {},
...result.meta !== undefined ? { meta: result.meta } : {}, ...meta !== undefined ? { meta } : {},
} }
// Validate BEFORE cloning: structuredClone turns some forbidden exotic or // One traversal both validates and detaches the accepted result. A separate
// class instances into plain objects, which would hide a lossy JSON // check followed by structuredClone would reread getters and could sanitize
// boundary violation. Validate the detached clone again to contain hostile // a class instance into an apparently valid plain record.
// getters whose value changes between inspection and snapshotting. const snapshot = snapshotJsonValue(candidate)
if (!isJsonValue(candidate)) { if (snapshot === undefined) {
throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult') throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult')
} }
const snapshot = structuredClone(candidate)
if (!isJsonValue(snapshot)) {
throw new TypeError('tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult')
}
return snapshot return snapshot
} }

View File

@@ -20,6 +20,7 @@
*/ */
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts' import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts' import type { ToolCallView, ToolResultView } from './presentation.ts'
@@ -353,6 +354,12 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted * Raw JSON-Schema tool definitions (from MCP servers) are still accepted
* by `ToolRegistry.register()` directly — `defineTool` is sugar for * by `ToolRegistry.register()` directly — `defineTool` is sugar for
* first-party plugin authors. * first-party plugin authors.
*
* Definition is an acceptance boundary: every top-level option is read once,
* and the parameter spec is detached before either the wire schema or the
* runtime validators are built. Later mutation of the caller's options or
* schema therefore cannot make the model-visible schema disagree with execute
* or presentation validation.
* @param options - the tool's name, description, typed parameter schema, * @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters. * execute body, and optional presenters.
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the * @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
@@ -362,6 +369,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* args). * args).
*/ */
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition { export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Capture every caller-owned top-level field before inspecting any nested
// schema value. Accessors may be stateful, so validation, presentation, and
// the returned definition must all derive from this one accepted record.
const name = options.name
const description = options.description
const inputParameters = options.parameters
const timeoutMs = options.timeoutMs
// Object-literal execute methods don't use `this`; the reference is safe. // Object-literal execute methods don't use `this`; the reference is safe.
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
const userExecute = options.execute const userExecute = options.execute
@@ -369,20 +383,31 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
const userPresentCall = options.presentCall const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult const userPresentResult = options.presentResult
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) throw new Error(`defineTool(${name}): timeoutMs must be a positive finite number`)
}
// The internal SchemaSpec and public wire schema must not share mutable
// subobjects. Each is materialized through the lossless one-pass boundary;
// structuredClone alone could sanitize an exotic default or nested getter.
const parameterSpec = snapshotJsonValue(inputParameters)
if (parameterSpec === undefined) {
throw new Error(`defineTool(${name}): parameters must be losslessly JSON-serializable`)
}
const wireParameters = snapshotJsonValue(schemaSpecToJsonSchema(parameterSpec))
if (wireParameters === undefined) {
throw new Error(`defineTool(${name}): generated parameters must be losslessly JSON-serializable`)
} }
const tool: ToolDefinition = { const tool: ToolDefinition = {
name: options.name, name,
description: options.description, description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>, parameters: wireParameters as unknown as Record<string, unknown>,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}),
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> { async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs. On // Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an // mismatch we throw ToolArgsError; the registry turns it into an
// isError result so the model can self-correct. After this guard, the // isError result so the model can self-correct. After this guard, the
// cast to InferArgs<S> reflects the validated shape. // cast to InferArgs<S> reflects the validated shape.
const violations = validateArgs(options.parameters, args) const violations = validateArgs(parameterSpec, args)
if (violations.length > 0) throw new ToolArgsError(violations) if (violations.length > 0) throw new ToolArgsError(violations)
return userExecute(args as InferArgs<S>, exec) return userExecute(args as InferArgs<S>, exec)
}, },
@@ -393,13 +418,13 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
// than the hard `ToolArgsError` the execute path raises. // than the hard `ToolArgsError` the execute path raises.
if (userPresentCall) { if (userPresentCall) {
tool.presentCall = (args: unknown): ToolCallView | undefined => { tool.presentCall = (args: unknown): ToolCallView | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined if (validateArgs(parameterSpec, args).length > 0) return undefined
return userPresentCall(args as InferArgs<S>) return userPresentCall(args as InferArgs<S>)
} }
} }
if (userPresentResult) { if (userPresentResult) {
tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => { tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined if (validateArgs(parameterSpec, args).length > 0) return undefined
return userPresentResult(args as InferArgs<S>, result) return userPresentResult(args as InferArgs<S>, result)
} }
} }

View File

@@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken, ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm'
@@ -141,6 +141,25 @@ describe('restrict()', () => {
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b']) expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
}) })
it('reads restriction accessors once so the checked filter is the enforced filter', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('global'))
let allowReads = 0
const filter = {
get allow(): string[] | undefined {
allowReads += 1
return allowReads === 1 ? [] : undefined
},
} as ToolRestriction
scope.ctx.tools.restrict(filter)
expect(allowReads).toBe(1)
expect(ctx.tools.schemas(key)).toEqual([])
expect(await run(ctx, 'global', key)).toBe('Error: unknown tool "global"')
})
it('fails loud on an unscoped call, an empty filter, and unknown names', async () => { it('fails loud on an unscoped call, an empty filter, and unknown names', async () => {
const ctx = await mount() const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a') const { scope } = await mintAgentScope(ctx, 'a')
@@ -372,6 +391,116 @@ describe('scoped execution dispatch', () => {
expect(Object.isFrozen(forged)).toBe(false) expect(Object.isFrozen(forged)).toBe(false)
}) })
it('reads a stateful parent accessor once before policy, dispatch, and result observation', async () => {
const ctx = await mount()
const observed: (ToolExecutionToken | undefined)[] = []
ctx.tools.register({
...tool('t'),
execute: (_args, exec) => {
observed.push(exec.parent)
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
},
})
ctx.on('tools/pre-execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/result', (exec) => { observed.push(exec.parent) })
const forged = { fake: true } as unknown as ToolExecutionToken
let parentReads = 0
const input = {
callId: CallId('stateful-parent'),
name: 't',
arguments: {},
get parent(): ToolExecutionToken | undefined {
parentReads += 1
return parentReads === 1 ? undefined : forged
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(false)
expect(parentReads).toBe(1)
expect(observed).toEqual([undefined, undefined, undefined, undefined])
})
it('uses one input snapshot for the normalized error shell', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'accepted')
const driftAgent = { id: 'drift' as AgentId } as Agent
ctx.tools.register(tool('parent'))
ctx.tools.register(tool('t'))
let parent!: ToolExecutionToken
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
const acceptedSignal = new AbortController().signal
const driftSignal = new AbortController().signal
const forged = { fake: true } as unknown as ToolExecutionToken
const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 }
const input = {
get callId() { reads.callId += 1; return CallId('unstable-error') },
get name() { reads.name += 1; return 't' },
get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } },
get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent },
get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged },
get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal },
} as ToolExecutionInput
let observed: Readonly<ToolExecution> | undefined
let scopedObserved = 0
ctx.on('tools/result', (exec) => { observed = exec })
scope.ctx.on('tools/result', () => { scopedObserved += 1 })
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 })
expect(scopedObserved).toBe(1)
expect(observed).toMatchObject({
callId: CallId('unstable-error'),
name: 't',
agent: key,
parent,
signal: acceptedSignal,
})
expect(Object.isFrozen(observed)).toBe(true)
})
it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let argumentReads = 0
let observed = 0
ctx.on('tools/result', (exec, result) => {
observed += 1
expect(exec.arguments).toBeUndefined()
expect(result.isError).toBe(true)
})
const input = {
callId: CallId('throwing-arguments'),
name: 't',
get arguments(): unknown {
argumentReads += 1
throw new Error('getter exploded')
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }])
expect(argumentReads).toBe(1)
expect(observed).toBe(1)
})
it.each([ it.each([
['Map', new Map([['mutable', true]])], ['Map', new Map([['mutable', true]])],
['class instance', new (class Arguments { value = 1 })()], ['class instance', new (class Arguments { value = 1 })()],
@@ -408,7 +537,7 @@ describe('scoped execution dispatch', () => {
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 }) expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
}) })
it('rejects arguments that change to non-JSON data while being snapshotted', async () => { it('reads nested arguments once into the executed snapshot', async () => {
const ctx = await mount() const ctx = await mount()
ctx.tools.register(tool('t')) ctx.tools.register(tool('t'))
let reads = 0 let reads = 0
@@ -421,12 +550,11 @@ describe('scoped execution dispatch', () => {
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue, callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
}) })
expect(reads).toBe(1)
expect(result).toEqual({ expect(result).toEqual({
callId: CallId('unstable-arguments'), callId: CallId('unstable-arguments'),
content: [{ content: [{ type: 'text', text: 'ran:t' }],
type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data', isError: false,
}],
isError: true,
}) })
}) })

View File

@@ -6,8 +6,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import ToolRegistry, { import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, type DefineToolOptions, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult, type ToolGuard, type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolGuard,
} from '@deepseek-ai/dsh-tools' } from '@deepseek-ai/dsh-tools'
async function setup() { async function setup() {
@@ -135,7 +135,7 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true) expect(observedError).toBe(true)
}) })
it('normalizes a result that changes to non-JSON data while being snapshotted', async () => { it('reads each result value once so later getter drift cannot change the snapshot', async () => {
const ctx = await setup() const ctx = await setup()
ctx.tools.register(echoTool) ctx.tools.register(echoTool)
let reads = 0 let reads = 0
@@ -153,15 +153,59 @@ describe('ToolRegistry', () => {
callId: CallId('unstable-result'), name: 'echo', arguments: {}, callId: CallId('unstable-result'), name: 'echo', arguments: {},
}) })
expect(reads).toBe(1)
expect(result).toEqual({ expect(result).toEqual({
callId: CallId('unstable-result'), callId: CallId('unstable-result'),
content: [{ content: [{ type: 'text', text: 'safe' }],
type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult', isError: false,
}],
isError: true,
}) })
}) })
it('reads every top-level execution result field once before validation', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const reads = { callId: 0, content: 0, isError: 0, error: 0, additionalContext: 0, meta: 0 }
ctx.on('tools/execute', async exec => Object.defineProperties({}, {
callId: { enumerable: true, get: () => { reads.callId += 1; return reads.callId === 1 ? exec.callId : CallId('drifted') } },
content: { enumerable: true, get: () => { reads.content += 1; return reads.content === 1 ? [{ type: 'text', text: 'accepted' }] : [] } },
isError: { enumerable: true, get: () => { reads.isError += 1; return reads.isError !== 1 } },
error: { enumerable: true, get: () => { reads.error += 1; return undefined } },
additionalContext: { enumerable: true, get: () => { reads.additionalContext += 1; return undefined } },
meta: { enumerable: true, get: () => { reads.meta += 1; return undefined } },
}) as ToolExecutionResult)
const result = await ctx.tools.execute({
callId: CallId('one-read-result'), name: 'echo', arguments: {},
})
expect(reads).toEqual({ callId: 1, content: 1, isError: 1, error: 1, additionalContext: 1, meta: 1 })
expect(result).toEqual({
callId: CallId('one-read-result'),
content: [{ type: 'text', text: 'accepted' }],
isError: false,
})
})
it('rejects an exotic nested result before its prototype can be sanitized', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
class ExoticText { readonly value = 'not text' }
ctx.on('tools/execute', exec => Promise.resolve({
callId: exec.callId,
content: [{ type: 'text', text: new ExoticText() }],
isError: false,
} as unknown as ToolExecutionResult))
const result = await ctx.tools.execute({
callId: CallId('exotic-result'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text', text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult',
}])
})
it('returns isError results for unknown tools and throwing tools', async () => { it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup() const ctx = await setup()
ctx.tools.register({ ctx.tools.register({
@@ -729,6 +773,29 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true) expect(observedError).toBe(true)
}) })
it('rejects non-JSON data at the defensive final-result notification boundary', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let execution: ToolExecution | undefined
ctx.on('tools/execute', async (exec, next) => {
execution = exec
return next()
})
await ctx.tools.execute({ callId: CallId('capture-execution'), name: 'echo', arguments: {} })
if (execution === undefined) throw new Error('test fixture did not capture the execution')
const internal = ctx.tools as unknown as {
notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise<void>
}
const invalid = {
callId: CallId('capture-execution'),
content: new Map() as unknown as ToolExecutionResult['content'],
isError: false,
}
await expect(internal.notifyResult(execution, invalid))
.rejects.toThrow('tool result notification must be losslessly JSON-serializable')
})
it.each([ it.each([
{ {
name: 'non-object result', name: 'non-object result',
@@ -780,6 +847,11 @@ describe('ToolRegistry', () => {
replacement: { kind: 'defer' }, replacement: { kind: 'defer' },
message: 'tools/post-execute must return an accept or block decision', message: 'tools/post-execute must return an accept or block decision',
}, },
{
name: 'non-JSON decision',
replacement: { kind: 'accept', content: new Map() },
message: 'tools/post-execute must return a losslessly JSON-serializable decision',
},
])('normalizes a tools/post-execute $name', async ({ replacement, message }) => { ])('normalizes a tools/post-execute $name', async ({ replacement, message }) => {
const ctx = await setup() const ctx = await setup()
ctx.tools.register(echoTool) ctx.tools.register(echoTool)
@@ -886,7 +958,7 @@ describe('ToolRegistry', () => {
expect(ctx.tools.get('invalid-parameters')).toBeUndefined() expect(ctx.tools.get('invalid-parameters')).toBeUndefined()
}) })
it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => { it('reads nested tool parameters once into the accepted snapshot', async () => {
const ctx = await setup() const ctx = await setup()
let reads = 0 let reads = 0
const parameters = Object.defineProperty({}, 'properties', { const parameters = Object.defineProperty({}, 'properties', {
@@ -898,8 +970,58 @@ describe('ToolRegistry', () => {
...echoTool, ...echoTool,
name: 'unstable-parameters', name: 'unstable-parameters',
parameters, parameters,
})).toThrow('tool parameters must be stable losslessly JSON-serializable data') })).not.toThrow()
expect(ctx.tools.get('unstable-parameters')).toBeUndefined() expect(reads).toBe(1)
expect(ctx.tools.get('unstable-parameters')?.parameters).toEqual({ properties: {} })
})
it('reads a top-level parameters accessor once so validation and storage use one value', async () => {
const ctx = await setup()
const accepted = { type: 'object', properties: { accepted: { type: 'string' } } }
class DriftedParameters {
readonly type = 'object'
readonly properties = { drifted: { type: 'number' } }
}
let reads = 0
const definition = { ...echoTool, name: 'top-level-parameters' }
Object.defineProperty(definition, 'parameters', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? accepted : new DriftedParameters()
},
})
ctx.tools.register(definition)
expect(reads).toBe(1)
expect(ctx.tools.get('top-level-parameters')?.parameters).toEqual(accepted)
})
it('rejects malformed fixed definition fields without freezing caller objects', async () => {
const ctx = await setup()
const badName = { value: 'object-name' }
const badDescription = { value: 'object-description' }
const badTimeout = { value: 100 }
expect(() => ctx.tools.register({ ...echoTool, name: badName as unknown as string }))
.toThrow('tool name must be a string')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-description', description: badDescription as unknown as string }))
.toThrow('description must be a string')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-timeout', timeoutMs: badTimeout as unknown as number }))
.toThrow('timeoutMs must be a positive finite number')
expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))
.toThrow('timeoutMs must be a positive finite number')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-execute', execute: { bind() {} } as unknown as typeof echoTool.execute }))
.toThrow('execute must be a function')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-present-call', presentCall: 1 as unknown as NonNullable<ToolDefinition['presentCall']> }))
.toThrow('presentCall must be a function')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-present-result', presentResult: 1 as unknown as NonNullable<ToolDefinition['presentResult']> }))
.toThrow('presentResult must be a function')
expect(Object.isFrozen(badName)).toBe(false)
expect(Object.isFrozen(badDescription)).toBe(false)
expect(Object.isFrozen(badTimeout)).toBe(false)
expect(ctx.tools.schemas()).toEqual([])
}) })
it('snapshots callbacks while preserving their registration-time method receiver', async () => { it('snapshots callbacks while preserving their registration-time method receiver', async () => {
@@ -1094,6 +1216,101 @@ describe('defineTool / schema DSL', () => {
expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }]) expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
}) })
it('reads defineTool options once and keeps wire and runtime schemas on one detached snapshot', async () => {
const accepted: SchemaSpec = { value: { type: 'string', required: true, enum: ['accepted'] } }
const drifted: SchemaSpec = { count: { type: 'number', required: true } }
const reads = {
name: 0,
description: 0,
parameters: 0,
timeoutMs: 0,
execute: 0,
presentCall: 0,
presentResult: 0,
}
const options = {} as DefineToolOptions<SchemaSpec>
Object.defineProperties(options, {
name: { enumerable: true, get: () => { reads.name += 1; return reads.name === 1 ? 'accepted' : 'drifted' } },
description: { enumerable: true, get: () => { reads.description += 1; return reads.description === 1 ? 'accepted description' : 'drifted description' } },
parameters: { enumerable: true, get: () => { reads.parameters += 1; return reads.parameters === 1 ? accepted : drifted } },
timeoutMs: { enumerable: true, get: () => { reads.timeoutMs += 1; return reads.timeoutMs === 1 ? 250 : 0 } },
execute: {
enumerable: true,
get: () => {
reads.execute += 1
return (args: Record<string, unknown>) => Promise.resolve([{ type: 'text' as const, text: String(args['value']) }])
},
},
presentCall: {
enumerable: true,
get: () => {
reads.presentCall += 1
return (args: Record<string, unknown>) => ({ card: 'generic' as const, title: String(args['value']) })
},
},
presentResult: {
enumerable: true,
get: () => {
reads.presentResult += 1
return (args: Record<string, unknown>) => ({ card: 'generic' as const, title: String(args['value']) })
},
},
})
const tool = defineTool(options)
accepted.value!.type = 'number'
accepted.value!.enum!.push('mutated')
expect(tool).toMatchObject({
name: 'accepted',
description: 'accepted description',
timeoutMs: 250,
parameters: {
type: 'object',
properties: { value: { type: 'string', enum: ['accepted'] } },
required: ['value'],
},
})
await expect(tool.execute({ value: 'accepted' }, {} as ToolExecution))
.resolves.toEqual([{ type: 'text', text: 'accepted' }])
expect(tool.presentCall?.({ value: 'accepted' })).toEqual({ card: 'generic', title: 'accepted' })
expect(tool.presentResult?.(
{ value: 'accepted' },
{ content: [], isError: false },
)).toEqual({ card: 'generic', title: 'accepted' })
expect(reads).toEqual({
name: 1,
description: 1,
parameters: 1,
timeoutMs: 1,
execute: 1,
presentCall: 1,
presentResult: 1,
})
})
it('rejects an exotic defineTool schema before it can be normalized for the wire', () => {
class ExoticDefault { readonly value = 'not JSON' }
expect(() => defineTool({
name: 'exotic-schema',
description: 'must reject exotic defaults',
parameters: {
value: { type: 'string', default: new ExoticDefault() },
},
execute: () => Promise.resolve([]),
})).toThrow(/parameters must be losslessly JSON-serializable/)
})
it('rejects a malformed defineTool spec whose generated wire schema is not JSON', () => {
expect(() => defineTool({
name: 'malformed-schema',
description: 'missing property type',
parameters: { value: {} } as unknown as SchemaSpec,
execute: () => Promise.resolve([]),
})).toThrow(/generated parameters must be losslessly JSON-serializable/)
})
it('type-level: InferArgs maps required properties to non-optional', () => { it('type-level: InferArgs maps required properties to non-optional', () => {
// Compile-time check: if this compiles, InferArgs is correct. // Compile-time check: if this compiles, InferArgs is correct.
// args.a is string (required), args.b is number|undefined (optional). // args.a is string (required), args.b is number|undefined (optional).

View File

@@ -29,4 +29,4 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
## Write path ## Write path
The plugin generalizes the example `session-jsonl.ts`: it subscribes to `session/created` (capture the header; persist a fork's seed once), `session/event` (snapshot each event when buffering — the live `session.events` object is mutable), and `session/flush`/dispose (drain the write-behind buffer through `append`). A per-session write cursor means a resumed session never re-appends already-stored events. Existing live sessions are seeded on plugin apply (HMR does not replay `session/created`). All backend operations for one session are serialized, and disposal awaits quiescence (every init + final drain) before returning, so no write lands after teardown. The plugin generalizes the example `session-jsonl.ts`: it subscribes to `session/created` (capture the header; persist a fork's seed once), `session/event` (copy each already-frozen event into the persistence-owned write-behind buffer), and `session/flush`/dispose (drain that buffer through `append`). A per-session write cursor means a resumed session never re-appends already-stored events. Existing live sessions are seeded on plugin apply (HMR does not replay `session/created`). All backend operations for one session are serialized, and disposal awaits quiescence (every init + final drain) before returning, so no write lands after teardown.

View File

@@ -27,4 +27,4 @@ interface Config {
## Write path ## Write path
Like the JSONL backend, the plugin also installs the `session/event` → buffer → `session/flush` drain: it snapshots each event when buffered (the live `session.events` object is mutable), persists a fork's seed once on `session/created`, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay `session/created`). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown. Like the JSONL backend, the plugin also installs the `session/event` → buffer → `session/flush` drain: it copies each already-frozen event into a persistence-owned buffer, persists a fork's seed once on `session/created`, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay `session/created`). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown.

View File

@@ -17,7 +17,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded. - **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
- **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq. - **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq.
- **JSON-serializable data.** `append` rejects non-serializable `event.data`; backends snapshot each event when buffering (the live `session.events` object is mutable). - **JSON-serializable data.** `append` materializes each direct/replay batch through the shared one-pass lossless-JSON boundary. Live `Session` events are already deep-frozen, but the write coordinator still copies each event into a persistence-owned buffer.
- **Durability.** `append` returns only once the batch is durable. - **Durability.** `append` returns only once the batch is durable.
## The write coordinator ## The write coordinator

View File

@@ -25,9 +25,9 @@
*/ */
import { Context } from 'cordis' import { Context } from 'cordis'
import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import { assertSerializable, seedCoversPrefix } from './index.ts' import { seedCoversPrefix } from './index.ts'
/** /**
* A stored session's durable prefix as read back from a backend: its * A stored session's durable prefix as read back from a backend: its
@@ -186,14 +186,18 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/** /**
* Register a new session's metadata (lazy: no physical write until the first * Register a new session's metadata (lazy: no physical write until the first
* {@link append}). Rejects if the id is already tracked or already persisted. * {@link append}). Rejects if the id is already tracked or already persisted.
* @param meta - the immutable header (id, version, cwd, lineage) to record; snapshotted at call time. * @param meta - the header (id, version, cwd, lineage) to record; materialized
* as a detached lossless-JSON snapshot at call time.
*/ */
create(meta: SessionHeader): Promise<void> { create(meta: SessionHeader): Promise<void> {
// Snapshot the metadata at call time: the op runs later (behind the // Snapshot the metadata at call time: the op runs later (behind the
// per-session chain) and the snapshot is stored as the lazy state, so keeping // per-session chain) and the snapshot is stored as the lazy state, so keeping
// the caller's object by reference would let a later mutation of `id`/`cwd` // the caller's object by reference would let a later mutation of `id`/`cwd`
// register under one key but materialize under a different path/header. // register under one key but materialize under a different path/header.
const snapshot: SessionHeader = { ...meta } const snapshot = snapshotJsonValue(meta)
if (snapshot === undefined) {
return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable'))
}
return this.serialize(snapshot.id, () => this.createCore(snapshot)) return this.serialize(snapshot.id, () => this.createCore(snapshot))
} }
@@ -212,23 +216,25 @@ export class PersistenceCoordinator<TornMarker = unknown> {
this.states.set(meta.id, { meta, cursor: 0, materialized: false }) this.states.set(meta.id, { meta, cursor: 0, materialized: false })
} }
// `async` so the synchronous validate/clone below reject (not throw) per the // `async` so synchronous materialization failures below reject (not throw) per
// Promise<void> contract — callers use `await expect(...).rejects`. // the Promise<void> contract — callers use `await expect(...).rejects`.
/** /**
* Durably persist a batch of events. Honors the append-only and contiguous-seq * Durably persist a batch of events. Honors the append-only and contiguous-seq
* contracts; rejects non-JSON-serializable `event.data`. * contracts; rejects non-JSON-serializable `event.data`.
* @param id - the session the batch belongs to. * @param id - the session the batch belongs to.
* @param events - the contiguous batch to persist, in seq order; deep-cloned at call time. * @param events - the contiguous batch to persist, in seq order; materialized
* as a detached lossless-JSON snapshot at call time.
*/ */
async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> { async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
// Validate serializability BEFORE cloning so a bad event surfaces the typed // Validate and deep-snapshot the complete batch HERE, in one traversal,
// error rather than an opaque DataCloneError from structuredClone. // before the op waits behind the per-session chain. A check followed by
assertSerializable(events) // structuredClone would reread accessors and could sanitize an exotic value
// Deep-snapshot the batch HERE, before the op waits behind the per-session // into an apparently valid record; the single-pass materializer makes the
// chain: a caller that mutates a live array (e.g. session.events) — or an // checked value exactly the value persisted.
// event inside it — before the op runs would otherwise have those changes const batch = snapshotJsonValue(events)
// persisted. The clone is taken synchronously (at call time). if (batch === undefined) {
const batch = events.map(e => structuredClone(e)) throw new TypeError('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data')
}
return this.serialize(id, () => this.appendCore(id, batch)) return this.serialize(id, () => this.appendCore(id, batch))
} }
@@ -338,9 +344,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// promise so flush/dispose can await it (onCreated is async). // promise so flush/dispose can await it (onCreated is async).
ctx.on('session/created', (session) => { void this.initFor(session) }) ctx.on('session/created', (session) => { void this.initFor(session) })
// Snapshot + buffer every event (the live object is mutable; clone so a later // Session emits an owned frozen event. Keep a persistence-owned copy anyway
// in-place mutation cannot rewrite a buffered event). Serializability is // so the write-behind queue owns exactly the record it will flush rather than
// guaranteed at the source (Session.append), so structuredClone is safe. // retaining a product-layer record by identity. Serializability is guaranteed
// at the source, so structuredClone is safe.
ctx.on('session/event', (session, event) => { ctx.on('session/event', (session, event) => {
let buffer = this.buffers.get(session) let buffer = this.buffers.get(session)
if (!buffer) this.buffers.set(session, buffer = []) if (!buffer) this.buffers.set(session, buffer = [])
@@ -391,8 +398,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const existing = this.inits.get(session) const existing = this.inits.get(session)
if (existing) return existing if (existing) return existing
// Snapshot the seed SYNCHRONOUSLY — initFor runs inside the `session/created` // Snapshot the seed SYNCHRONOUSLY — initFor runs inside the `session/created`
// emit, before any later `append` adds non-seed events. A clone freezes it // emit, before any later append invalidates the public array snapshot. Events
// against later mutation of the live event objects. // are already frozen; cloning gives persistence independent ownership.
const seed = session.events.map(e => structuredClone(e)) const seed = session.events.map(e => structuredClone(e))
const p = this.onCreated(session, seed) const p = this.onCreated(session, seed)
// Attach a no-op rejection handler so a failing init does not surface as an // Attach a no-op rejection handler so a failing init does not surface as an

View File

@@ -22,7 +22,7 @@
*/ */
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import { isJsonValue } from '@deepseek-ai/dsh-session' import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
// Re-export the metadata vocabulary so consumers import it from the seam. // Re-export the metadata vocabulary so consumers import it from the seam.
@@ -58,16 +58,16 @@ export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly
} }
/** /**
* Reject non-JSON-serializable event data before a backend serializes a batch. * Reject a batch that is not wholly losslessly JSON-serializable. Live session
* Live session appends already enforce this; persistence append paths also * appends already enforce this; persistence append paths also accept replay or
* accept replay/fork batches that may bypass a live session instance. * direct batches that may bypass a live session instance. Validation uses the
* @param events - the batch to validate; throws naming the offending event's type and seq. * same one-pass materializer as the coordinator, so getters are read once.
* @param events - the complete event batch to validate.
*/ */
export function assertSerializable(events: readonly SessionEvent[]): void { export function assertSerializable(events: readonly SessionEvent[]): void {
for (const event of events) { const snapshot = snapshotJsonValue(events)
if (!isJsonValue(event.data)) { if (snapshot === undefined) {
throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`) throw new Error('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data')
}
} }
} }
@@ -90,11 +90,11 @@ export function assertSerializable(events: readonly SessionEvent[]): void {
* {@link load} rejects a parse error or a `seq` gap in the COMMITTED region * {@link load} rejects a parse error or a `seq` gap in the COMMITTED region
* (unloadable); {@link append}'s first event `seq` MUST equal the backend's * (unloadable); {@link append}'s first event `seq` MUST equal the backend's
* stored next-seq (after `load` has balanced any interrupted turn). * stored next-seq (after `load` has balanced any interrupted turn).
* - **JSON-serializable data.** `SessionEventMap` is merge-extensible and * - **JSON-serializable events.** `SessionEventMap` is merge-extensible, so
* `event.data` is typed only as `SessionEventMap[K]`, so {@link append} * {@link append} materializes each complete batch through the shared
* REJECTS non-JSON-serializable data with an error naming the offending * lossless-JSON boundary before buffering it. The public `session.events`
* event type. A backend snapshots (serializes/clones) each event when it * view is immutable, but persistence still snapshots direct/replay callers at
* buffers, since `session.events` hands out the live mutable object. * this independent trust boundary.
* - **Durability.** {@link append} returns only once the batch is durable * - **Durability.** {@link append} returns only once the batch is durable
* (the file backend fsyncs; a DB commits). {@link create} MAY defer the * (the file backend fsyncs; a DB commits). {@link create} MAY defer the
* physical write until the first {@link append} (lazy materialization). * physical write until the first {@link append} (lazy materialization).

View File

@@ -245,7 +245,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
const events = [ const events = [
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: bad } }, { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: bad } },
] as unknown as SessionEvent[] ] as unknown as SessionEvent[]
await expect(persistence.append(mi.id, events)).rejects.toThrow(/user\/message/) await expect(persistence.append(mi.id, events)).rejects.toThrow(/losslessly JSON-serializable/)
} }
} finally { } finally {
await dispose() await dispose()

View File

@@ -143,14 +143,15 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
} }
}) })
it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => { it('source-frozen events cannot be mutated after buffering and persist unchanged', async () => {
const fix = await makeFixture() const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix) const { ctx, fiber } = await freshCtx(fix)
try { try {
const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } })
const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
// Mutate the live event object AFTER it was buffered by session/event. expect(() => {
;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
}).toThrow(TypeError)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session) await ctx.parallel('session/flush', session)

View File

@@ -158,6 +158,17 @@ describe('SessionPersistence service registration', () => {
expect(loaded.events).toHaveLength(6) expect(loaded.events).toHaveLength(6)
await fiber.dispose() await fiber.dispose()
}) })
it('rejects non-JSON session metadata before registering lazy state', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence)
const invalid = { ...meta('invalid-meta'), createdAt: 1n as unknown as number }
await expect(ctx.sessionPersistence.create(invalid))
.rejects.toThrow('session metadata must be losslessly JSON-serializable')
await fiber.dispose()
})
}) })
describe('shared persistence helpers', () => { describe('shared persistence helpers', () => {
@@ -187,10 +198,10 @@ describe('shared persistence helpers', () => {
expect(() => { assertSerializable(oneTurnLog()) }).not.toThrow() expect(() => { assertSerializable(oneTurnLog()) }).not.toThrow()
}) })
it('rejects non-JSON-serializable event data with type and seq context', () => { it('rejects a batch containing non-JSON-serializable event data', () => {
const bad = [ const bad = [
{ type: 'user/message', seq: 0, time: 1, data: { content: 1n } }, { type: 'user/message', seq: 0, time: 1, data: { content: 1n } },
] as unknown as SessionEvent[] ] as unknown as SessionEvent[]
expect(() => { assertSerializable(bad) }).toThrow(/"user\/message".*seq 0/) expect(() => { assertSerializable(bad) }).toThrow(/batch is not losslessly JSON-serializable/)
}) })
}) })

View File

@@ -9,9 +9,9 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Public API ### Public API
- `ctx.skills.registerProvider(provider): () => Promise<void> | void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry snapshots the name and callback identities at registration, so replacing those fields later cannot change lookup or HMR cleanup; callbacks remain bound to the original provider object and can still read its mutable state. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. - `ctx.skills.registerProvider(provider): () => Promise<void> | void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry snapshots the name and callback identities at registration, so replacing those fields later cannot change lookup or HMR cleanup; callbacks remain bound to the original provider object and can still read its mutable state. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
- `ctx.skills.list({ cwd?, signal? })` Returns model-invocable skill summaries for the current workspace, merged across providers and sorted by name. - `ctx.skills.list({ cwd?, signal? })` Snapshots the lookup options, then returns detached model-invocable summaries for the current workspace, merged across providers and sorted by name.
- `ctx.skills.get(name, { cwd?, signal? })` Returns the full winning skill, including disabled-for-model skills. - `ctx.skills.get(name, { cwd?, signal? })` Uses one lookup-options snapshot to select and load the winner, rechecks cancellation after discovery or a cache hit, races provider loading against the same signal, then returns a detached full definition, including disabled-for-model skills.
- `ctx.skills.register(skill): () => Promise<void> | void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. - `ctx.skills.register(skill): () => Promise<void> | void` Registers a detached runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
### Config ### Config
@@ -21,13 +21,15 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
## Provider Contract ## Provider Contract
A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Registration copies `name` and binds the current `list` and `get` methods once; replacing those fields on the caller-owned object later does not rewrite the live registry entry, and disposal always removes the original name. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting an uncooperative provider so agent cancellation cannot hang prefix composition. The provider later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a remote provider can store a URL, id, or version token. A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Registration copies `name` and binds the current `list` and `get` methods once; replacing those fields on the caller-owned object later does not rewrite the live registry entry, and disposal always removes the original name. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting uncooperative discovery and loading work so agent cancellation cannot hang prefix composition or skill loading.
The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers. Each public lookup captures `cwd` and the abort-signal identity once before cache or provider work, and providers receive that frozen lookup record. The registry reads each returned candidate once, validates that snapshot, and detaches its resource metadata before caching it. The winning provider receives another detached candidate in `get(candidate, options)`, while `candidate.locator` preserves the exact provider-owned identity originally returned by `list()`; a local provider can therefore use a file-path handle while a remote provider can use a URL, id, or version token. A loaded definition is detached again before it reaches the caller.
The registry validates fixed provider, candidate, runtime-registration, and loaded-definition fields before detachment: names/descriptions/content use their declared string types, ranks are finite numbers, and `disableModelInvocation` is boolean when present. Caller-owned objects masquerading as scalars are rejected without being frozen. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed, registry-owned catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers.
## Runtime Skills ## Runtime Skills
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer. `ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration detaches the accepted definition and nested resource metadata; later mutation of the registration object or a returned list/get value cannot rewrite the live skill. Registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
## Consumer boundary ## Consumer boundary

View File

@@ -81,9 +81,10 @@ export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?:
/** Caller context used for cwd-sensitive and abortable provider work. */ /** Caller context used for cwd-sensitive and abortable provider work. */
export interface SkillLookupOptions { export interface SkillLookupOptions {
cwd?: string | undefined /** Workspace selector captured at lookup entry; providers receive a read-only snapshot. */
readonly cwd?: string | undefined
/** Abort discovery or loading work for the current caller. */ /** Abort discovery or loading work for the current caller. */
signal?: AbortSignal | undefined readonly signal?: AbortSignal | undefined
} }
/** Provider interface for one source of skills, such as local directories or a remote registry. */ /** Provider interface for one source of skills, such as local directories or a remote registry. */
@@ -101,7 +102,8 @@ export interface SkillProvider {
list(options: SkillLookupOptions): Promise<SkillCandidate[]> list(options: SkillLookupOptions): Promise<SkillCandidate[]>
/** /**
* Load a complete skill body for a previously listed candidate. * Load a complete skill body for a previously listed candidate.
* @param candidate - the winning candidate originally returned by this provider. * @param candidate - a detached snapshot of the winning candidate; its opaque
* `locator` retains the exact identity originally returned by this provider.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns the full skill body, or `undefined` if it is no longer loadable. * @returns the full skill body, or `undefined` if it is no longer loadable.
*/ */
@@ -194,10 +196,18 @@ export class SkillService extends Service {
// replacement of `provider.list`/`provider.get` after registration inert. // replacement of `provider.list`/`provider.get` after registration inert.
// In particular, cleanup must never re-read caller-owned `provider.name`: // In particular, cleanup must never re-read caller-owned `provider.name`:
// an HMR host may mutate or reuse that object before its old fiber unloads. // an HMR host may mutate or reuse that object before its old fiber unloads.
const name = provider.name
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputList = provider.list
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputGet = provider.get
if (typeof name !== 'string') throw new TypeError('skill provider name must be a string')
if (typeof inputList !== 'function') throw new TypeError(`skill provider "${name}" list must be a function`)
if (typeof inputGet !== 'function') throw new TypeError(`skill provider "${name}" get must be a function`)
const snapshot: SkillProvider = Object.freeze({ const snapshot: SkillProvider = Object.freeze({
name: provider.name, name,
list: provider.list.bind(provider), list: inputList.bind(provider),
get: provider.get.bind(provider), get: inputGet.bind(provider),
}) })
const dispose = this.ctx.effect(function* (this: SkillService) { const dispose = this.ctx.effect(function* (this: SkillService) {
if (snapshot.name === RUNTIME_PROVIDER) { if (snapshot.name === RUNTIME_PROVIDER) {
@@ -223,7 +233,9 @@ export class SkillService extends Service {
* Register a runtime skill contribution. Runtime registrations are treated as * Register a runtime skill contribution. Runtime registrations are treated as
* embedded provider entries with project-over-user priority. Same-name runtime * embedded provider entries with project-over-user priority. Same-name runtime
* registrations are first-wins: a duplicate logs a warning and gets a no-op * registrations are first-wins: a duplicate logs a warning and gets a no-op
* disposer so it cannot remove the active contribution. * disposer so it cannot remove the active contribution. The registry detaches
* the accepted definition, including nested resource metadata, so later caller
* mutation cannot rewrite the live contribution.
* @param skill - the complete skill definition to expose for discovery. * @param skill - the complete skill definition to expose for discovery.
* @returns the exact Cordis effect disposer that removes this runtime * @returns the exact Cordis effect disposer that removes this runtime
* contribution and invalidates caches; composite effects may yield it * contribution and invalidates caches; composite effects may yield it
@@ -250,12 +262,15 @@ export class SkillService extends Service {
} }
/** /**
* List model-invocable skill summaries for a workspace. * List model-invocable skill summaries for a workspace. The lookup options are
* snapshotted before discovery, and every returned summary is detached from the
* cached provider catalog.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @returns sorted summaries, excluding skills disabled for model invocation. * @returns sorted summaries, excluding skills disabled for model invocation.
*/ */
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> { async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
return (await this.collect(options)) const accepted = snapshotLookupOptions(options)
return (await this.collect(accepted))
.map(entry => entry.candidate) .map(entry => entry.candidate)
.filter(skill => skill.disableModelInvocation !== true) .filter(skill => skill.disableModelInvocation !== true)
.map(toSummary) .map(toSummary)
@@ -263,20 +278,32 @@ export class SkillService extends Service {
} }
/** /**
* Load one full skill definition by name. * Load one full skill definition by name. One lookup-options snapshot selects
* and loads the winner; the provider receives detached candidate metadata with
* its opaque locator identity preserved, and the returned definition is also
* detached from provider-owned data. Cancellation is rechecked after catalog
* selection (including a cache hit), and provider loading is raced against the
* same signal so an uncooperative provider cannot hang the caller.
* @param name - kebab-case skill name. * @param name - kebab-case skill name.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns the full skill, including body content, or `undefined`. * @returns the full skill, including body content, or `undefined`.
*/ */
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> { async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
if (!isSkillName(name)) return undefined if (!isSkillName(name)) return undefined
const match = (await this.collect(options)).find(entry => entry.candidate.name === name) const accepted = snapshotLookupOptions(options)
const collected = await this.collect(accepted)
throwIfAborted(accepted.signal)
const match = collected.find(entry => entry.candidate.name === name)
if (match === undefined) return undefined if (match === undefined) return undefined
return await match.provider.get(match.candidate, options) const definition = await waitWithAbort(
match.provider.get(copyCandidate(match.candidate), accepted),
accepted.signal,
)
return definition === undefined ? undefined : snapshotDefinition(definition)
} }
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> { private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
options.signal?.throwIfAborted() throwIfAborted(options.signal)
while (true) { while (true) {
const providerRevision = this.providerRevision const providerRevision = this.providerRevision
const runtimeRevision = this.runtimeRevision const runtimeRevision = this.runtimeRevision
@@ -285,7 +312,7 @@ export class SkillService extends Service {
if (cached !== undefined) return cached if (cached !== undefined) return cached
const result = await this.collectFresh(options) const result = await this.collectFresh(options)
options.signal?.throwIfAborted() throwIfAborted(options.signal)
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
if (result.cacheable) { if (result.cacheable) {
this.collectCache.set(key, result.entries) this.collectCache.set(key, result.entries)
@@ -316,7 +343,7 @@ export class SkillService extends Service {
} }
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> { private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
options.signal?.throwIfAborted() throwIfAborted(options.signal)
const candidates: IndexedCandidate[] = [] const candidates: IndexedCandidate[] = []
let cacheable = true let cacheable = true
let runtimeOrder = 0 let runtimeOrder = 0
@@ -340,9 +367,12 @@ export class SkillService extends Service {
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`) this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
} }
if (listed === undefined) continue if (listed === undefined) continue
if (!Array.isArray(listed)) {
throw new TypeError(`skill provider "${provider.name}" list() must return an array`)
}
for (const candidate of listed) { for (const candidate of listed) {
validateCandidate(candidate, provider.name) const snapshot = snapshotCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder }) candidates.push({ candidate: snapshot, provider, providerOrder: order, localOrder })
localOrder += 1 localOrder += 1
} }
} }
@@ -377,28 +407,161 @@ function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
} }
} }
/** Read provider candidate data once and detach it while preserving its opaque locator identity. */
function copyCandidate(candidate: SkillCandidate, providerName?: string): SkillCandidate {
const name = candidate.name
const description = candidate.description
const whenToUse = candidate.whenToUse
const disableModelInvocation = candidate.disableModelInvocation
const source = candidate.source
const provider = candidate.provider
const resourceBase = candidate.resourceBase
const rank = candidate.rank
const locator = candidate.locator
const path = candidate.path
const metadata = candidate.metadata
const accepted: SkillCandidate = {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
source,
provider,
...resourceBase !== undefined ? { resourceBase } : {},
rank,
// `locator` is the one deliberately provider-owned capability in a
// candidate. Its exact identity must round-trip back to provider.get().
locator,
...path !== undefined ? { path } : {},
...metadata !== undefined ? { metadata } : {},
}
// Validate the exact scalar snapshot before cloning nested data. This keeps a
// malformed candidate's provider-contract error from being masked by an
// unrelated DataCloneError in its metadata.
if (providerName !== undefined) validateCandidate(accepted, providerName)
return {
...accepted,
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
...metadata !== undefined ? { metadata: structuredClone(metadata) } : {},
}
}
/** Normalize one provider result into the registry-owned catalog snapshot. */
function snapshotCandidate(candidate: SkillCandidate, providerName: string): SkillCandidate {
return copyCandidate(candidate, providerName)
}
function validateCandidate(candidate: SkillCandidate, providerName: string): void { function validateCandidate(candidate: SkillCandidate, providerName: string): void {
if (typeof candidate.name !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`)
}
if (!SKILL_NAME.test(candidate.name)) { if (!SKILL_NAME.test(candidate.name)) {
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`) throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
} }
if (typeof candidate.description !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`)
}
if (candidate.description.length === 0) { if (candidate.description.length === 0) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`) throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
} }
if (!Number.isFinite(candidate.rank)) { if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`)
}
if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)
}
if (typeof candidate.source !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`)
}
if (typeof candidate.rank !== 'number' || !Number.isFinite(candidate.rank)) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`) throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
} }
if (typeof candidate.provider !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`)
}
if (candidate.provider !== providerName) { if (candidate.provider !== providerName) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`) throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
} }
if (candidate.path !== undefined && typeof candidate.path !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`)
}
} }
function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition { function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition {
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`) // Read every caller-owned top-level field once so validation and storage use
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`) // one coherent definition even when JavaScript accessors are involved.
const name = skill.name
const description = skill.description
const whenToUse = skill.whenToUse
const disableModelInvocation = skill.disableModelInvocation
const source = skill.source
const inputProvider = skill.provider
const provider = inputProvider === undefined ? RUNTIME_PROVIDER : inputProvider
const resourceBase = skill.resourceBase
const content = skill.content
const path = skill.path
const metadata = skill.metadata
if (typeof name !== 'string') throw new TypeError('runtime skill name must be a string')
if (!SKILL_NAME.test(name)) throw new Error(`invalid skill name "${name}"`)
if (typeof description !== 'string') throw new TypeError(`skill "${name}" description must be a string`)
if (description.length === 0) throw new Error(`skill "${name}" requires a description`)
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
throw new TypeError(`skill "${name}" disableModelInvocation must be a boolean`)
}
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`skill "${name}" whenToUse must be a string`)
if (typeof source !== 'string') throw new TypeError(`skill "${name}" source must be a string`)
if (typeof provider !== 'string') throw new TypeError(`skill "${name}" provider must be a string`)
if (typeof content !== 'string') throw new TypeError(`skill "${name}" content must be a string`)
if (path !== undefined && typeof path !== 'string') throw new TypeError(`skill "${name}" path must be a string`)
return { return {
...skill, name,
provider: skill.provider ?? RUNTIME_PROVIDER, description,
source: skill.source, ...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
source,
provider,
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
content,
...path !== undefined ? { path } : {},
...metadata !== undefined ? { metadata: structuredClone(metadata) } : {},
}
}
/** Detach a provider-loaded definition before it crosses back to the caller. */
function snapshotDefinition(skill: SkillDefinition): SkillDefinition {
const name = skill.name
const description = skill.description
const whenToUse = skill.whenToUse
const disableModelInvocation = skill.disableModelInvocation
const source = skill.source
const provider = skill.provider
const resourceBase = skill.resourceBase
const content = skill.content
const path = skill.path
const metadata = skill.metadata
if (typeof name !== 'string') throw new TypeError('loaded skill name must be a string')
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`)
if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`)
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`)
}
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)
if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
if (typeof content !== 'string') throw new TypeError(`loaded skill "${name}" content must be a string`)
if (path !== undefined && typeof path !== 'string') throw new TypeError(`loaded skill "${name}" path must be a string`)
return {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
source,
provider,
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
content,
...path !== undefined ? { path } : {},
...metadata !== undefined ? { metadata: structuredClone(metadata) } : {},
} }
} }
@@ -411,7 +574,7 @@ function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
...disableModelInvocation !== undefined ? { disableModelInvocation } : {}, ...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
source, source,
provider, provider,
...resourceBase !== undefined ? { resourceBase } : {}, ...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
} }
} }
@@ -441,9 +604,19 @@ function collectCacheKey(options: SkillLookupOptions, providerRevision: number,
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision }) return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
} }
/** Capture one lookup identity before any provider or cache async boundary. */
function snapshotLookupOptions(options: SkillLookupOptions): Readonly<SkillLookupOptions> {
const cwd = options.cwd
const signal = options.signal
return Object.freeze({
...cwd !== undefined ? { cwd } : {},
...signal !== undefined ? { signal } : {},
})
}
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> { function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return promise if (signal === undefined) return promise
signal.throwIfAborted() throwIfAborted(signal)
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const cleanup = (): void => { const cleanup = (): void => {
signal.removeEventListener('abort', onAbort) signal.removeEventListener('abort', onAbort)
@@ -467,12 +640,28 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined):
}) })
} }
function toError(error: unknown): Error { /** Throw a total Error for an already-aborted lookup. */
return error instanceof Error ? error : new Error(String(error)) function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted === true) throw toError(signal.reason)
} }
/** Normalize an arbitrary abort or provider failure without trusting coercion. */
function toError(error: unknown): Error {
try {
if (error instanceof Error) return error
} catch {
// A hostile proxy may throw during instanceof; fall through to the total renderer.
}
return new Error(errorMessage(error))
}
/** Render an arbitrary provider failure without letting coercion escape containment. */
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
return String(error) try {
return String(error)
} catch {
return '[unrenderable thrown value]'
}
} }
export default SkillService export default SkillService

View File

@@ -165,6 +165,480 @@ describe('SkillService registry', () => {
expect(() => ctx.skills.registerProvider(replacement)).not.toThrow() expect(() => ctx.skills.registerProvider(replacement)).not.toThrow()
}) })
it('rejects malformed provider and candidate scalar fields without freezing caller objects', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const badProviderName = { value: 'object-provider' }
expect(() => ctx.skills.registerProvider({
name: badProviderName as unknown as string,
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
})).toThrow('skill provider name must be a string')
expect(Object.isFrozen(badProviderName)).toBe(false)
expect(() => ctx.skills.registerProvider({
name: 'bad-list',
list: { bind() {} } as unknown as SkillProvider['list'],
get: () => Promise.resolve(undefined),
})).toThrow('list must be a function')
expect(() => ctx.skills.registerProvider({
name: 'bad-get',
list: () => Promise.resolve([]),
get: { bind() {} } as unknown as SkillProvider['get'],
})).toThrow('get must be a function')
const badDescription = { value: 'object-description' }
ctx.skills.registerProvider({
name: 'bad-candidate',
list: () => Promise.resolve([{
...memorySkill('bad-candidate', 'placeholder', 1),
provider: 'bad-candidate',
description: badDescription as unknown as string,
disableModelInvocation: 'false' as unknown as boolean,
}]),
get: () => Promise.resolve(undefined),
})
await expect(ctx.skills.list()).rejects.toThrow('non-string description')
expect(Object.isFrozen(badDescription)).toBe(false)
const badBoolean = new Context()
await badBoolean.plugin(SkillService)
badBoolean.skills.registerProvider({
name: 'bad-boolean',
list: () => Promise.resolve([{
...memorySkill('bad-boolean', 'Bad boolean', 1),
provider: 'bad-boolean',
disableModelInvocation: 'false' as unknown as boolean,
}]),
get: () => Promise.resolve(undefined),
})
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
})
it('rejects non-array provider results and every malformed candidate scalar', async () => {
const badList = new Context()
await badList.plugin(SkillService)
badList.skills.registerProvider({
name: 'non-array-list',
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
get: () => Promise.resolve(undefined),
})
await expect(badList.skills.list()).rejects.toThrow('list() must return an array')
const cases: { patch: Partial<SkillCandidate>; expected: string }[] = [
{ patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' },
{ patch: { whenToUse: 1 as unknown as string }, expected: 'non-string whenToUse' },
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'non-string source' },
{ patch: { rank: '1' as unknown as number }, expected: 'invalid rank' },
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'non-string provider' },
{ patch: { path: 1 as unknown as string }, expected: 'non-string path' },
]
for (const [index, { patch, expected }] of cases.entries()) {
const ctx = new Context()
await ctx.plugin(SkillService)
const providerName = `candidate-provider-${index}`
const candidate = {
name: `candidate-${index}`,
description: 'Candidate',
whenToUse: 'Use this candidate.',
disableModelInvocation: false,
provider: providerName,
source: 'test',
rank: 1,
locator: 'candidate',
path: '/skills/candidate/SKILL.md',
...patch,
} as SkillCandidate
ctx.skills.registerProvider({
name: providerName,
list: () => Promise.resolve([candidate]),
get: () => Promise.resolve(undefined),
})
await expect(ctx.skills.list()).rejects.toThrow(expected)
}
})
it('snapshots lookup options before asynchronous discovery and loading', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let release: (() => void) | undefined
const gate = new Promise<void>((resolve) => { release = resolve })
const listCwds: (string | undefined)[] = []
const getCwds: (string | undefined)[] = []
ctx.skills.registerProvider({
name: 'contextual',
async list(options) {
listCwds.push(options.cwd)
await gate
const name = options.cwd === '/workspace/a' ? 'skill-a' : 'skill-b'
return [
{ name, description: name, provider: 'contextual', source: 'test', rank: 1, locator: name },
{ name: 'vanished', description: 'Vanished', provider: 'contextual', source: 'test', rank: 2, locator: 'vanished' },
]
},
async get(candidate, options) {
getCwds.push(options.cwd)
if (candidate.name === 'vanished') return undefined
return { ...candidate, content: `${options.cwd}:${candidate.name}` }
},
})
const listOptions: { cwd: string | undefined } = { cwd: '/workspace/a' }
const pending = ctx.skills.list(listOptions)
listOptions.cwd = '/workspace/b'
release?.()
expect((await pending).map(skill => skill.name)).toEqual(['skill-a', 'vanished'])
expect((await ctx.skills.list({ cwd: '/workspace/a' })).map(skill => skill.name)).toEqual(['skill-a', 'vanished'])
expect(listCwds).toEqual(['/workspace/a'])
const getOptions: { cwd: string | undefined } = { cwd: '/workspace/a' }
const loading = ctx.skills.get('skill-a', getOptions)
getOptions.cwd = '/workspace/b'
expect((await loading)?.content).toBe('/workspace/a:skill-a')
expect(await ctx.skills.get('vanished', { cwd: '/workspace/a' })).toBeUndefined()
expect(getCwds).toEqual(['/workspace/a', '/workspace/a'])
})
it('rechecks cancellation after cached discovery before provider loading', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let getCalls = 0
ctx.skills.registerProvider({
name: 'cached',
async list() {
return [{
name: 'cached-skill',
description: 'Cached skill',
provider: 'cached',
source: 'test',
rank: 1,
locator: 'cached',
}]
},
async get(candidate) {
getCalls += 1
return { ...candidate, content: 'Cached body.' }
},
})
await ctx.skills.list({ cwd: '/workspace/cache' })
const controller = new AbortController()
const reason = new Error('cancelled after cached discovery')
const pending = ctx.skills.get('cached-skill', {
cwd: '/workspace/cache',
signal: controller.signal,
})
controller.abort(reason)
await expect(pending).rejects.toBe(reason)
expect(getCalls).toBe(0)
})
it('stops waiting for cached provider loading when a hostile abort reason fires', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let markStarted: (() => void) | undefined
let release: (() => void) | undefined
let seenSignal: AbortSignal | undefined
const started = new Promise<void>((resolve) => { markStarted = resolve })
const held = new Promise<SkillDefinition>((resolve) => {
release = () => {
resolve({
name: 'held-skill',
description: 'Held skill',
provider: 'held',
source: 'test',
content: 'Held body.',
})
}
})
ctx.skills.registerProvider({
name: 'held',
async list() {
return [{
name: 'held-skill',
description: 'Held skill',
provider: 'held',
source: 'test',
rank: 1,
locator: 'held',
}]
},
get(_candidate, options) {
seenSignal = options.signal
markStarted?.()
return held
},
})
await ctx.skills.list({ cwd: '/workspace/cache' })
const controller = new AbortController()
const hostileReason = {
[Symbol.toPrimitive]() {
throw new Error('abort reason coercion failed')
},
}
const pending = ctx.skills.get('held-skill', {
cwd: '/workspace/cache',
signal: controller.signal,
})
const outcome = pending.then(
() => 'resolved',
(error: unknown) => error instanceof Error && error.message === '[unrenderable thrown value]'
? 'aborted'
: 'other-error',
)
await started
controller.abort(hostileReason)
const settled = await Promise.race([
outcome,
new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 25)),
])
release?.()
await pending.catch(() => undefined)
expect(seenSignal).toBe(controller.signal)
expect(settled).toBe('aborted')
})
it('detaches cached candidates and loaded definitions while preserving locator identity', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const locator = { id: 'provider-owned' }
const candidate: SkillCandidate = {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
rank: 1,
locator,
path: '/skills/stable/SKILL.md',
metadata: { owner: 'candidate' },
}
const definition: SkillDefinition = {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'definition resources' },
path: '/skills/stable/SKILL.md',
metadata: { owner: 'definition' },
content: 'Stable body.',
}
let listCalls = 0
let received: SkillCandidate | undefined
ctx.skills.registerProvider({
name: 'detached',
async list() {
listCalls += 1
return [candidate]
},
async get(loaded) {
received = loaded
return definition
},
})
const first = await ctx.skills.list()
candidate.name = 'Bad_Name'
candidate.description = ''
if (candidate.resourceBase?.kind === 'opaque') candidate.resourceBase.description = 'mutated candidate'
if (candidate.metadata) candidate.metadata.owner = 'mutated candidate'
if (first[0]?.resourceBase?.kind === 'opaque') first[0].resourceBase.description = 'mutated summary'
const second = await ctx.skills.list()
expect(second).toEqual([expect.objectContaining({
name: 'stable-skill',
description: 'Stable description',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
})])
expect(listCalls).toBe(1)
const loaded = await ctx.skills.get('stable-skill')
expect(received).not.toBe(candidate)
expect(received?.locator).toBe(locator)
expect(received).toMatchObject({
name: 'stable-skill',
description: 'Stable description',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
metadata: { owner: 'candidate' },
})
expect(loaded).not.toBe(definition)
if (loaded?.resourceBase?.kind === 'opaque') loaded.resourceBase.description = 'mutated definition output'
if (loaded?.metadata) loaded.metadata.owner = 'mutated definition output'
expect(await ctx.skills.get('stable-skill')).toMatchObject({
resourceBase: { kind: 'opaque', description: 'definition resources' },
metadata: { owner: 'definition' },
})
expect(definition).toMatchObject({
resourceBase: { kind: 'opaque', description: 'definition resources' },
metadata: { owner: 'definition' },
})
})
it('detaches runtime registrations and every public resource view', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' }
const metadata = { owner: 'runtime' }
ctx.skills.register({
name: 'runtime-skill',
description: 'Runtime',
whenToUse: 'When runtime data is needed.',
disableModelInvocation: false,
source: 'runtime',
resourceBase,
metadata,
content: 'Runtime body.',
})
ctx.skills.register({
name: 'z-runtime',
description: 'Second runtime skill',
source: 'runtime',
content: 'Second runtime body.',
})
resourceBase.description = 'mutated registration'
metadata.owner = 'mutated registration'
const listed = await ctx.skills.list()
const loaded = await ctx.skills.get('runtime-skill')
expect(listed[0]?.resourceBase).toEqual({ kind: 'opaque', description: 'runtime resources' })
expect(loaded?.metadata).toEqual({ owner: 'runtime' })
if (listed[0]?.resourceBase?.kind === 'opaque') listed[0].resourceBase.description = 'mutated list output'
if (loaded?.resourceBase?.kind === 'opaque') loaded.resourceBase.description = 'mutated get output'
if (loaded?.metadata) loaded.metadata.owner = 'mutated get output'
expect((await ctx.skills.list())[0]?.resourceBase).toEqual({ kind: 'opaque', description: 'runtime resources' })
expect(await ctx.skills.get('runtime-skill')).toMatchObject({
resourceBase: { kind: 'opaque', description: 'runtime resources' },
metadata: { owner: 'runtime' },
})
})
it('rejects malformed runtime and loaded-definition scalar fields without freezing them', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const runtimeDescription = { value: 'runtime-description' }
expect(() => ctx.skills.register({
name: 'bad-runtime',
description: runtimeDescription as unknown as string,
source: 'runtime',
content: 'body',
})).toThrow('description must be a string')
expect(Object.isFrozen(runtimeDescription)).toBe(false)
expect(() => ctx.skills.register({
name: 'bad-runtime-boolean',
description: 'Runtime',
disableModelInvocation: 'false' as unknown as boolean,
source: 'runtime',
content: 'body',
})).toThrow('disableModelInvocation must be a boolean')
expect(() => ctx.skills.register({
name: 'bad-runtime-provider',
description: 'Runtime',
source: 'runtime',
provider: null as unknown as string,
content: 'body',
})).toThrow('provider must be a string')
const loadedContent = { value: 'loaded-content' }
ctx.skills.registerProvider({
name: 'bad-definition',
list: () => Promise.resolve([{
name: 'bad-definition',
description: 'Candidate',
provider: 'bad-definition',
source: 'test',
rank: 1,
locator: 'bad-definition',
}]),
get: candidate => Promise.resolve({
...candidate,
content: loadedContent as unknown as string,
}),
})
await expect(ctx.skills.get('bad-definition')).rejects.toThrow('content must be a string')
expect(Object.isFrozen(loadedContent)).toBe(false)
})
it('rejects every other malformed runtime scalar', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
type Registration = Parameters<typeof ctx.skills.register>[0]
const valid: Registration = {
name: 'runtime-validation',
description: 'Runtime validation',
whenToUse: 'Use this runtime skill.',
disableModelInvocation: false,
source: 'runtime',
provider: 'runtime-validation',
content: 'Runtime body.',
path: '/skills/runtime-validation/SKILL.md',
}
const cases: { patch: Partial<Registration>; expected: string }[] = [
{ patch: { name: { value: 'runtime' } as unknown as string }, expected: 'runtime skill name must be a string' },
{ patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' },
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' },
{ patch: { content: { value: 'content' } as unknown as string }, expected: 'content must be a string' },
{ patch: { path: 1 as unknown as string }, expected: 'path must be a string' },
]
for (const { patch, expected } of cases) {
expect(() => ctx.skills.register({ ...valid, ...patch })).toThrow(expected)
}
})
it('rejects every malformed scalar in provider-loaded definitions', async () => {
const cases: { patch: Partial<SkillDefinition>; expected: string }[] = [
{ patch: { name: { value: 'loaded' } as unknown as string }, expected: 'loaded skill name must be a string' },
{ patch: { name: 'Bad_Name' }, expected: 'loaded skill has invalid name' },
{ patch: { description: { value: 'description' } as unknown as string }, expected: 'description must be a string' },
{ patch: { description: '' }, expected: 'requires a description' },
{ patch: { disableModelInvocation: 'false' as unknown as boolean }, expected: 'disableModelInvocation must be a boolean' },
{ patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' },
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' },
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'provider must be a string' },
{ patch: { content: { value: 'content' } as unknown as string }, expected: 'content must be a string' },
{ patch: { path: 1 as unknown as string }, expected: 'path must be a string' },
]
for (const [index, { patch, expected }] of cases.entries()) {
const ctx = new Context()
await ctx.plugin(SkillService)
const providerName = `definition-provider-${index}`
const skillName = `definition-${index}`
ctx.skills.registerProvider({
name: providerName,
list: () => Promise.resolve([{
name: skillName,
description: 'Candidate',
provider: providerName,
source: 'test',
rank: 1,
locator: 'definition',
}]),
get: () => Promise.resolve({
name: skillName,
description: 'Definition',
whenToUse: 'Use this definition.',
disableModelInvocation: false,
provider: providerName,
source: 'test',
content: 'Definition body.',
path: '/skills/definition/SKILL.md',
...patch,
} as SkillDefinition),
})
await expect(ctx.skills.get(skillName)).rejects.toThrow(expected)
}
})
it('validates provider candidates and invalid registry caps', async () => { it('validates provider candidates and invalid registry caps', async () => {
const defaultedService = new SkillService(new Context()) const defaultedService = new SkillService(new Context())
expect(await defaultedService.list()).toEqual([]) expect(await defaultedService.list()).toEqual([])
@@ -282,6 +756,34 @@ describe('SkillService registry', () => {
expect(flakyCalls).toBe(3) expect(flakyCalls).toBe(3)
}) })
it('contains a provider rejection whose string coercion throws', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const hostileFailure = {
toString() {
throw new Error('provider failure coercion failed')
},
}
ctx.skills.registerProvider({
name: 'hostile-failure',
list() {
// Deliberately violate the provider contract to prove containment is total.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
return Promise.reject(hostileFailure)
},
async get() {
return undefined
},
})
await expect(ctx.skills.list()).resolves.toEqual([])
expect(warnings).toEqual([
'skill provider "hostile-failure" skipped: [unrenderable thrown value]',
])
})
it('abandons an in-flight catalog when provider registrations change', async () => { it('abandons an in-flight catalog when provider registrations change', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SkillService) await ctx.plugin(SkillService)

View File

@@ -23,10 +23,9 @@ const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }]
/** /**
* Drives the REAL fork backend with a real loop + scripted mock MODEL + the * Drives the REAL fork backend with a real loop + scripted mock MODEL + the
* real dsh-invariants plugin. The invariants plugin re-replays a seeded child * real dsh-invariants plugin. The plugin replays a seeded child log on
* log on `session/created` (its freeze-check), so a malformed (unbalanced) fork * `session/created`, so a malformed (unbalanced) fork seed makes these tests
* seed makes these tests THROW — that is the regression guard for the * THROW — that is the regression guard for the completed-turn-prefix boundary.
* completed-turn-prefix boundary.
*/ */
async function setup(script: Script) { async function setup(script: Script) {
const ctx = new Context() const ctx = new Context()

View File

@@ -8,7 +8,7 @@ The shared **in-process subagent run driver**. A library with no provider or imp
Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
1. snapshots the accepted request before asynchronous owner setup: the parent and signal remain identity capabilities but are never reread from the caller-owned record; tool filter, seed, agent options, output schema, and prompt are detached. It computes child depth = `depthOf(parent) + 1` and rejects `request.maxDepth` overflow with `SubagentDepthError`; `outputSchema` is asserted before cloning so a hostile value fails as `OutputSchemaError`, while the prompt passes the session log's lossless-JSON check before and after cloning; 1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It computes child depth = `depthOf(parent) + 1`, rejects `request.maxDepth` overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed;
2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists; 2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists;
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).

View File

@@ -18,9 +18,9 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import type { Context, Fiber } from 'cordis' import type { Context, Fiber } from 'cordis'
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent' import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, isJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { SessionId, snapshotJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { import {
attachStructuredRuntime, attachStructuredRuntime,
@@ -125,59 +125,74 @@ export function startInProcessRun(
request: SubagentStartRequest, request: SubagentStartRequest,
options: InProcessRunOptions, options: InProcessRunOptions,
): SubagentRun { ): SubagentRun {
// Snapshot the accepted request synchronously. The parent and signal are // Capture every top-level field once. Parent/signal are identity capabilities;
// identity capabilities (kept live but never reread from the mutable request // every data value is materialized below before asynchronous owner setup.
// record); every data field is detached before asynchronous owner setup.
const parent = request.parent const parent = request.parent
const signal = request.signal const signal = request.signal
const persona = request.persona const persona = request.persona
const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter) const inputToolFilter = request.toolFilter
const seed = options.seed === undefined ? undefined : structuredClone(options.seed) const inputMaxDepth = request.maxDepth
const inputSchema = request.outputSchema
const inputPrompt = request.prompt
const inputAgentOptions = request.agentOptions
const inputSeed = options.seed
const toolFilter = inputToolFilter === undefined ? undefined : snapshotJsonValue(inputToolFilter)
if (inputToolFilter !== undefined && toolFilter === undefined) {
throw new TypeError('subagent tool filter must be losslessly JSON-serializable')
}
const seed = inputSeed === undefined ? undefined : snapshotJsonValue(inputSeed)
if (inputSeed !== undefined && seed === undefined) {
throw new TypeError('subagent seed must be losslessly JSON-serializable')
}
const childDepth = depthOf(parent) + 1 const childDepth = depthOf(parent) + 1
if (request.maxDepth !== undefined && childDepth > request.maxDepth) { if (inputMaxDepth !== undefined && childDepth > inputMaxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth) throw new SubagentDepthError(childDepth, inputMaxDepth)
} }
// Assert, then snapshot, the schema subset BEFORE any child exists (the const requestedAgentOptions = inputAgentOptions === undefined
// service has already capability-gated; this rejects a schema outside the ? {}
// enforced subset loud). Assertion comes FIRST so a hostile value fails as : snapshotJsonValue(inputAgentOptions)
// OutputSchemaError, never as structuredClone's raw DataCloneError — the if (requestedAgentOptions === undefined) {
// asserted subset is plain JSON data, which always clones. The snapshot is throw new TypeError('subagent agent options must be losslessly JSON-serializable')
// load-bearing: the caller keeps its reference, so attaching the ORIGINAL }
// would let a post-start() mutation drift the enforced schema away from the // Materialize, then assert, the schema subset BEFORE any child exists. The
// asserted one — the clone (taken synchronously with the assertion, no // single traversal rejects non-JSON data without rereading accessors; the
// interleaving possible) pins assertion, the model-visible parameters, and // detached value then pins assertion, model-visible parameters, and runtime
// validateStructuredValue to one isolation-immutable value. // validation to one provider-owned schema. Contract failures stay typed as
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) // OutputSchemaError rather than leaking a materialization detail.
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema) const schema = inputSchema === undefined ? undefined : snapshotJsonValue(inputSchema)
if (inputSchema !== undefined && schema === undefined) {
throw new OutputSchemaError(['schema annotation must be JSON data; the complete schema must be losslessly JSON-serializable'])
}
if (schema !== undefined) assertSupportedOutputSchema(schema)
// The accepted request owns a value snapshot, not the caller's mutable // The accepted request owns a value snapshot, not the caller's mutable
// content array. Validate the same lossless-JSON contract Session.append // content array. Use the same one-pass boundary Session.append enforces before
// enforces before any child exists, then detach it synchronously so mutation // any child exists so later mutation cannot change what is logged or sent.
// during async creation cannot change what is logged or sent to the model. const prompt = snapshotJsonValue(inputPrompt)
if (!isJsonValue(request.prompt)) { if (prompt === undefined) {
throw new TypeError('subagent prompt must be losslessly JSON-serializable') throw new TypeError('subagent prompt must be losslessly JSON-serializable')
} }
const prompt = structuredClone(request.prompt)
if (!isJsonValue(prompt)) {
throw new TypeError('subagent prompt must be stable losslessly JSON-serializable data')
}
const childId = AgentId(randomUUID()) const childId = AgentId(randomUUID())
// The child's OWN events begin after the seed (fork seeds the parent's // The child's OWN events begin after the seed (fork seeds the parent's
// completed-turn prefix; spawn seeds nothing). `readResult` scopes to this // completed-turn prefix; spawn seeds nothing). `readResult` scopes to this
// boundary so a child that produces no message of its own never returns the // boundary so a child that produces no message of its own never returns the
// SEEDED parent's last assistant message as its result. // SEEDED parent's last assistant message as its result.
const seedLength = options.seed?.length ?? 0 const seedLength = seed?.length ?? 0
const parentHeader = parent.session.header const parentHeader = parent.session.header
// Inherit the parent's model by default (a child with no model cannot run); // Inherit the parent's model by default (a child with no model cannot run);
// an explicit `request.agentOptions.model` overrides it. The deployment // an explicit `request.agentOptions.model` overrides it. The deployment
// persona needs no inheritance (a context-wide section both render); a // persona needs no inheritance (a context-wide section both render); a
// per-child `request.persona` becomes a SCOPED section of the same name in // per-child `request.persona` becomes a SCOPED section of the same name in
// the setup below, shadowing the deployment's for this child alone. // the setup below, shadowing the deployment's for this child alone.
const agentOptions: AgentOptions = structuredClone({ const parentModel = parent.options.model
...parent.options.model !== undefined ? { model: parent.options.model } : {}, const agentOptions = snapshotJsonValue<AgentOptions>({
...request.agentOptions, ...parentModel !== undefined ? { model: parentModel } : {},
...requestedAgentOptions,
subagentDepth: childDepth, subagentDepth: childDepth,
}) })
if (agentOptions === undefined) {
throw new TypeError('subagent agent options must be losslessly JSON-serializable')
}
// The child's scoped world, composed in the factory's unpublished setup // The child's scoped world, composed in the factory's unpublished setup
// window. The factory awaits it before inserting or announcing the child, so // window. The factory awaits it before inserting or announcing the child, so

View File

@@ -79,8 +79,8 @@ export interface StructuredAttachment {
* agent-creation `setup` window with the child's scope context — every * agent-creation `setup` window with the child's scope context — every
* registration rides the child's fiber and unwinds with the child. * registration rides the child's fiber and unwinds with the child.
* @param childCtx - the child agent's scope context (`setup`'s argument). * @param childCtx - the child agent's scope context (`setup`'s argument).
* @param schema - the isolation-cloned, already-asserted schema subset to * @param schema - the detached, already-asserted schema subset to enforce (see
* enforce (see `assertSupportedOutputSchema` in dsh-tools). * `assertSupportedOutputSchema` in dsh-tools).
* @returns the attachment handle (read `captured()` after the child settles). * @returns the attachment handle (read `captured()` after the child settles).
*/ */
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment { export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {

View File

@@ -1,15 +1,15 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis' import { Context, type Fiber } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm' import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session' import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop' import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants' import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' import { depthOf, type InProcessRunOptions, SubagentDepthError, startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0] type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -57,7 +57,7 @@ describe('startInProcessRun', () => {
}, {})).toThrow('subagent prompt must be losslessly JSON-serializable') }, {})).toThrow('subagent prompt must be losslessly JSON-serializable')
}) })
it('rejects a prompt whose getter becomes non-JSON while it is snapshotted', async () => { it('reads each prompt value once before asynchronous child creation', async () => {
const { ctx, parent } = await setup([]) const { ctx, parent } = await setup([])
let reads = 0 let reads = 0
const prompt = [{ const prompt = [{
@@ -68,9 +68,91 @@ describe('startInProcessRun', () => {
}, },
}] }]
expect(() => startInProcessRun(ctx, { prompt, parent }, {})) const run = startInProcessRun(ctx, { prompt, parent }, {})
.toThrow('subagent prompt must be stable losslessly JSON-serializable data') expect(reads).toBe(1)
expect(reads).toBe(2) await run.dispose()
})
it('reads each public request and seed option field once', async () => {
const { ctx, parent } = await setup([])
const reads = { prompt: 0, toolFilter: 0, maxDepth: 0, outputSchema: 0, agentOptions: 0, persona: 0, seed: 0 }
const request = Object.defineProperties({ parent }, {
prompt: { enumerable: true, get: () => { reads.prompt += 1; return [{ type: 'text', text: 'accepted' }] } },
toolFilter: { enumerable: true, get: () => { reads.toolFilter += 1; return reads.toolFilter === 1 ? undefined : { deny: ['ghost'] } } },
maxDepth: { enumerable: true, get: () => { reads.maxDepth += 1; return reads.maxDepth === 1 ? undefined : 0 } },
outputSchema: { enumerable: true, get: () => { reads.outputSchema += 1; return undefined } },
agentOptions: { enumerable: true, get: () => { reads.agentOptions += 1; return {} } },
persona: { enumerable: true, get: () => { reads.persona += 1; return undefined } },
}) as unknown as SubagentStartRequest
const options = Object.defineProperty({}, 'seed', {
enumerable: true,
get: () => { reads.seed += 1; return reads.seed === 1 ? undefined : [] },
}) as InProcessRunOptions
const run = startInProcessRun(ctx, request, options)
expect(reads).toEqual({ prompt: 1, toolFilter: 1, maxDepth: 1, outputSchema: 1, agentOptions: 1, persona: 1, seed: 1 })
await run.dispose()
})
it('rejects an exotic seed before asynchronous owner setup can sanitize it', async () => {
const { ctx, parent } = await setup([])
class ExoticSeedEvent {
readonly type = 'turn/start'
readonly seq = 0
readonly time = 1
readonly data = { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }
}
expect(() => startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'accepted' }],
parent,
}, { seed: [new ExoticSeedEvent()] as unknown as SessionEvent[] }))
.toThrow(/subagent seed must be losslessly JSON-serializable/)
})
it.each([
{
label: 'tool filter',
overrides: { toolFilter: { deny: [Number.NaN as unknown as string] } },
message: 'subagent tool filter must be losslessly JSON-serializable',
},
{
label: 'agent options',
overrides: { agentOptions: { model: Number.NaN as unknown as string } },
message: 'subagent agent options must be losslessly JSON-serializable',
},
{
label: 'output schema',
overrides: {
outputSchema: {
type: 'object',
properties: { answer: { type: Number.NaN } },
} as unknown as NonNullable<SubagentStartRequest['outputSchema']>,
},
message: 'schema annotation must be JSON data',
},
])('rejects non-JSON $label before asynchronous child creation', async ({ overrides, message }) => {
const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'accepted' }],
parent,
...overrides,
}, {})).toThrow(message)
})
it('rejects a non-JSON model inherited from the parent before child creation', async () => {
const { ctx, parent } = await setup([])
const invalidParent = {
options: { ...parent.options, model: Number.NaN as unknown as string },
session: parent.session,
} as unknown as Agent
expect(() => startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'accepted' }],
parent: invalidParent,
}, {})).toThrow('subagent agent options must be losslessly JSON-serializable')
}) })
it('rejects when the run-owner fiber settles without installing its context', async () => { it('rejects when the run-owner fiber settles without installing its context', async () => {

View File

@@ -21,7 +21,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
| `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | | `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). | | `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). |
| `list()` | Registered provider names (insertion order). | | `list()` | Registered provider names (insertion order). |
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start`. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | | `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Return a frozen service-owned run wrapper whose provider fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |
## Capabilities: two kinds, discovered two ways ## Capabilities: two kinds, discovered two ways
@@ -32,12 +32,12 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `
## Run lifecycle ## Run lifecycle
`provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. `provider.start(request)` returns a provider-owned `SubagentRun`; `SubagentService.start` captures that handle once and returns a frozen service-owned wrapper with `started` (the publication/readiness promise), a normalized `result` (the terminal outcome), bound `cancel()` and `dispose()`, and bound optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with one detached, deeply frozen `SubagentResult` (`output`, optional `structured`, `stopReason`) that the service and caller share — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), but malformed provider data rejects as an infrastructure contract fault. The consumer maps a non-`completed` reason to an `isError` tool result and MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: `subagent/start` (payload `SubagentRunInfo`) fires only after `run.started` fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) fires only for that announced run; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits. The service observes `result` immediately even while readiness is pending, clones its output before the caller can mutate it, and buffers that end payload until start has fired; a rejecting result cannot become an unhandled detached promise, start always precedes end, and a listener cannot corrupt the caller's result. `subagent/end` carries the cloned output as `lastAssistantMessage` on the settle path and omits it on infrastructure rejection. Any run-affecting decision is out of scope for this observe-only surface. The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: the service captures the provider handle's public fields once, `subagent/start` (payload `SubagentRunInfo`) fires only after the accepted `started` promise fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) uses the same accepted id and normalized result; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits whose service-owned payloads are deeply frozen before per-listener dispatch. The service observes the normalized `result` immediately even while readiness is pending and buffers that end payload until start has fired; a malformed provider result rejects the returned result promise and becomes contained `error` telemetry, a rejection cannot become an unhandled detached promise, start always precedes end, and one listener cannot corrupt either the caller or later listeners. `subagent/end` carries the same frozen output as `lastAssistantMessage` on a valid settle path and omits it on infrastructure or result-contract failure. Any run-affecting decision is out of scope for this observe-only surface.
## Scope (first cut) ## Scope (first cut)
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background, poll, and spill semantics are outside this seam; long-running-tool handling is shared work across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
See `src/types.ts` for the full contracts. See `src/types.ts` for the full contracts.

View File

@@ -25,6 +25,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6" "cordis": "^4.0.0-rc.6"
}, },
@@ -32,6 +33,7 @@
"@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6" "cordis": "^4.0.0-rc.6"
} }

View File

@@ -34,10 +34,11 @@
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope' import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope'
import { HarnessError } from '@deepseek-ai/dsh-llm' import { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import type { import type {
SubagentCapabilities, SubagentCapabilities,
@@ -115,7 +116,7 @@ declare module 'cordis' {
} }
} }
/** Identifying detail for a started subagent run (the `subagent/start` payload). */ /** Deep-frozen, observe-only identifying detail for a started subagent run. */
export interface SubagentRunInfo { export interface SubagentRunInfo {
/** The provider that started the run. */ /** The provider that started the run. */
provider: string provider: string
@@ -123,7 +124,7 @@ export interface SubagentRunInfo {
id: AgentId id: AgentId
} }
/** Outcome detail for a settled subagent run (the `subagent/end` payload). */ /** Deep-frozen, observe-only outcome detail for a settled subagent run. */
export interface SubagentRunEndInfo { export interface SubagentRunEndInfo {
/** The provider that ran it. */ /** The provider that ran it. */
provider: string provider: string
@@ -186,11 +187,12 @@ export class SubagentService extends Service {
// mutate or reuse the provider object before its old fiber unloads. Binding // mutate or reuse the provider object before its old fiber unloads. Binding
// preserves the provider method's receiver while making replacement of the // preserves the provider method's receiver while making replacement of the
// public callback field after registration inert. // public callback field after registration inert.
const inputCapabilities = provider.capabilities
const capabilities: SubagentCapabilities = Object.freeze({ const capabilities: SubagentCapabilities = Object.freeze({
outputSchema: provider.capabilities.outputSchema, outputSchema: inputCapabilities.outputSchema,
depthLimit: provider.capabilities.depthLimit, depthLimit: inputCapabilities.depthLimit,
toolFilter: provider.capabilities.toolFilter, toolFilter: inputCapabilities.toolFilter,
persona: provider.capabilities.persona, persona: inputCapabilities.persona,
}) })
const snapshot: SubagentProvider = Object.freeze({ const snapshot: SubagentProvider = Object.freeze({
name: provider.name, name: provider.name,
@@ -244,10 +246,16 @@ export class SubagentService extends Service {
/** /**
* Start a subagent run on the named provider. Resolves the provider (throws * Start a subagent run on the named provider. Resolves the provider (throws
* `NO_PROVIDER` if absent), validates every requested START-TIME capability * `NO_PROVIDER` if absent), reads the caller request once into a coherent
* acceptance snapshot, validates every requested START-TIME capability
* against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY` * against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY`
* for the first unmet one — fail loud, before any child is created), then * for the first unmet one — fail loud, before any child is created), then
* delegates to {@link SubagentProvider.start}, then emits `subagent/start` / * validates the request's scalar values, materializes model-bound data in one
* lossless-JSON traversal, and delegates the detached request to
* {@link SubagentProvider.start}. The returned handle is a service-owned,
* frozen wrapper: provider fields are captured once, methods stay bound to the
* provider handle, and `result` resolves to one detached, deeply frozen value
* shared by the caller and lifecycle telemetry. Emits `subagent/start` /
* `subagent/end` only after the run's readiness boundary fulfills. A provider * `subagent/end` only after the run's readiness boundary fulfills. A provider
* that fails before establishing a child emits neither event. * that fails before establishing a child emits neither event.
* @param name - the provider to run on. * @param name - the provider to run on.
@@ -255,32 +263,94 @@ export class SubagentService extends Service {
* @returns the live run (its `result` resolves when the child settles). * @returns the live run (its `result` resolves when the child settles).
*/ */
start(name: string, request: SubagentStartRequest): SubagentRun { start(name: string, request: SubagentStartRequest): SubagentRun {
// Parent is the lifecycle scope identity accepted at start. Never reread it
// from the caller-owned request after the provider/result async boundary,
// or start/end could be dispatched into different agent scopes.
const parent = request.parent
const provider = this.providers.get(name) const provider = this.providers.get(name)
if (!provider) { if (!provider) {
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER') throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
} }
this.assertCapabilities(provider, request) // Read every top-level field exactly once before capability checks or
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) // detachment. A stateful accessor must not look absent to validation and then
// appear in the provider request (or vice versa).
const input = this.snapshotStartRequest(request)
const parent = input.parent
this.assertCapabilities(provider, input)
if (input.maxDepth !== undefined && (
!Number.isSafeInteger(input.maxDepth)
|| input.maxDepth < 0
|| Object.is(input.maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
if (input.persona !== undefined && typeof input.persona !== 'string') {
throw new TypeError('subagent persona must be a string')
}
// Model/session-bound values are validated and detached in a single
// recursive pass. A check followed by structuredClone would reread getters
// and could erase an exotic prototype returned only to the clone.
const prompt = snapshotJsonValue(input.prompt)
if (prompt === undefined) {
throw new TypeError('subagent prompt must be losslessly JSON-serializable')
}
const outputSchema = input.outputSchema === undefined
? undefined
: snapshotJsonValue(input.outputSchema)
if (input.outputSchema !== undefined && outputSchema === undefined) {
throw new OutputSchemaError(['schema annotation must be JSON data; the complete schema must be losslessly JSON-serializable'])
}
if (outputSchema !== undefined) assertSupportedOutputSchema(outputSchema)
const agentOptions = input.agentOptions === undefined
? undefined
: snapshotJsonValue(input.agentOptions)
if (input.agentOptions !== undefined && agentOptions === undefined) {
throw new TypeError('subagent agent options must be losslessly JSON-serializable')
}
const toolFilter = input.toolFilter === undefined
? undefined
: snapshotJsonValue(input.toolFilter)
if (input.toolFilter !== undefined && toolFilter === undefined) {
throw new TypeError('subagent tool filter must be losslessly JSON-serializable')
}
// Detach every data field before crossing into a provider. Parent/signal // Detach every data field before crossing into a provider. Parent/signal
// are live identity capabilities and stay exact; the mutable request record // are live identity capabilities and stay exact; the mutable request record
// and its arrays/objects are never retained, so every backend (including an // and its arrays/objects are never retained, so every backend (including an
// async out-of-process one) observes the request accepted at start. // async out-of-process one) observes the request accepted at start.
const accepted: SubagentStartRequest = { const accepted: SubagentStartRequest = {
prompt: structuredClone(request.prompt), prompt,
parent, parent,
...request.signal !== undefined ? { signal: request.signal } : {}, ...input.signal !== undefined ? { signal: input.signal } : {},
...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {}, ...agentOptions !== undefined ? { agentOptions } : {},
...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {}, ...outputSchema !== undefined ? { outputSchema } : {},
...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {}, ...input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {},
...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {}, ...toolFilter !== undefined ? { toolFilter } : {},
...request.persona !== undefined ? { persona: request.persona } : {}, ...input.persona !== undefined ? { persona: input.persona } : {},
} }
const run = provider.start(accepted) const providerRun = provider.start(accepted)
// Provider-owned run objects can be accessor-backed too. Capture every
// public field exactly once, bind methods to the provider's original handle,
// and expose only this service-owned wrapper. The normalized result promise
// is also the one lifecycle telemetry observes, so the caller and observers
// cannot receive different values from stateful accessors.
const id = providerRun.id
const started = providerRun.started
const providerResult = providerRun.result
const cancel = providerRun.cancel.bind(providerRun)
const sendMessage = providerRun.sendMessage?.bind(providerRun)
const dispose = providerRun.dispose.bind(providerRun)
const resume = providerRun.resume?.bind(providerRun)
const result = providerResult.then(value => this.snapshotRunResult(value))
const run: SubagentRun = Object.freeze({
id,
started,
result,
cancel,
dispose,
...sendMessage === undefined
? {}
: { sendMessage },
...resume === undefined
? {}
: { resume },
})
// Observe result settlement IMMEDIATELY, before waiting on readiness. A // Observe result settlement IMMEDIATELY, before waiting on readiness. A
// provider may fail both promises in the same turn; deferring the rejection // provider may fail both promises in the same turn; deferring the rejection
@@ -296,26 +366,16 @@ export class SubagentService extends Service {
// remains observable by the run's consumer, but telemetry must not claim // remains observable by the run's consumer, but telemetry must not claim
// that a child started. // that a child started.
} }
void run.result.then( void result.then(
(result) => { (value) => {
// Snapshot before the caller's own `await run.result` continuation. Even
// when readiness is still pending, buffering the clone rather than the
// caller-owned result keeps the eventual observe-only event immutable
// with respect to consumer mutation.
let lastAssistantMessage: SubagentResult['output'] | undefined
try {
lastAssistantMessage = structuredClone(result.output)
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
}
deliverEnd({ deliverEnd({
provider: name, provider: name,
id: run.id, id,
stopReason: result.stopReason, stopReason: value.stopReason,
...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {}, lastAssistantMessage: value.output,
}) })
}, },
() => { deliverEnd({ provider: name, id: run.id, stopReason: 'error' }) }, () => { deliverEnd({ provider: name, id, stopReason: 'error' }) },
) )
// Readiness is the publication boundary owned by the provider. For // Readiness is the publication boundary owned by the provider. For
@@ -324,10 +384,10 @@ export class SubagentService extends Service {
// per-listener containment, then flush an outcome that settled unusually // per-listener containment, then flush an outcome that settled unusually
// early. A readiness rejection is handled here and deliberately emits no // early. A readiness rejection is handled here and deliberately emits no
// false start/end pair; the result path above remains independently handled. // false start/end pair; the result path above remains independently handled.
void run.started.then( void started.then(
() => { () => {
readiness = 'started' readiness = 'started'
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) this.emitLifecycle('subagent/start', { provider: name, id }, parent)
if (pendingEnd !== undefined) { if (pendingEnd !== undefined) {
const info = pendingEnd const info = pendingEnd
pendingEnd = undefined pendingEnd = undefined
@@ -342,6 +402,54 @@ export class SubagentService extends Service {
return run return run
} }
/** Normalize one provider result into the immutable seam value. */
private snapshotRunResult(value: SubagentResult): SubagentResult {
// Capture every provider-owned field once before validation. In particular,
// lifecycle telemetry must not reread accessors after the caller receives
// the result and observe a different terminal outcome.
const output = value.output
const structured = value.structured
const stopReason = value.stopReason
if (!Array.isArray(output)) {
throw new TypeError('subagent result output must be an array')
}
if (typeof stopReason !== 'string') {
throw new TypeError('subagent result stopReason must be a string')
}
const accepted: SubagentResult = {
output,
...structured === undefined ? {} : { structured },
stopReason,
}
const snapshot = snapshotJsonValue(accepted)
if (snapshot === undefined) {
throw new TypeError('subagent result must be losslessly JSON-serializable')
}
return deepFreeze(snapshot)
}
/** Read one coherent caller request into immutable data properties. */
private snapshotStartRequest(request: SubagentStartRequest): Readonly<SubagentStartRequest> {
const prompt = request.prompt
const parent = request.parent
const signal = request.signal
const agentOptions = request.agentOptions
const outputSchema = request.outputSchema
const maxDepth = request.maxDepth
const toolFilter = request.toolFilter
const persona = request.persona
return Object.freeze({
prompt,
parent,
...signal !== undefined ? { signal } : {},
...agentOptions !== undefined ? { agentOptions } : {},
...outputSchema !== undefined ? { outputSchema } : {},
...maxDepth !== undefined ? { maxDepth } : {},
...toolFilter !== undefined ? { toolFilter } : {},
...persona !== undefined ? { persona } : {},
})
}
/** /**
* Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch * Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch
* each subscriber individually and log (never propagate) a thrown one, so one * each subscriber individually and log (never propagate) a thrown one, so one
@@ -374,14 +482,15 @@ export class SubagentService extends Service {
// parent-scoped listener observes only its own delegations); the // parent-scoped listener observes only its own delegations); the
// provider-removed registry notification stays unfiltered. The carrier is // provider-removed registry notification stays unfiltered. The carrier is
// args[0] of the dispatch call, exactly as cordis' own emit spells it. // args[0] of the dispatch call, exactly as cordis' own emit spells it.
const acceptedInfo = typeof info === 'string' ? info : deepFreeze(info)
const dispatchArgs: unknown[] = parent === undefined const dispatchArgs: unknown[] = parent === undefined
? [name, info] ? [name, acceptedInfo]
: [scopeTarget(this, parent), name, info] : [scopeTarget(this, parent), name, acceptedInfo]
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) { for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
try { try {
callback(info) callback(acceptedInfo)
} catch (error: unknown) { } catch (error: unknown) {
this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`) this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
} }
} }
} }
@@ -409,4 +518,13 @@ export class SubagentService extends Service {
} }
} }
/** Render an arbitrary thrown value without allowing coercion to throw again. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
export default SubagentService export default SubagentService

View File

@@ -173,6 +173,16 @@ describe('SubagentService', () => {
persona: true, persona: true,
} }
const provider = new StubProvider('stable', capabilities) const provider = new StubProvider('stable', capabilities)
let capabilityReads = 0
let capabilityValue = capabilities
Object.defineProperty(provider, 'capabilities', {
configurable: true,
get: () => {
capabilityReads += 1
return capabilityValue
},
set: (value: SubagentCapabilities) => { capabilityValue = value },
})
const added: SubagentProvider[] = [] const added: SubagentProvider[] = []
const removed: string[] = [] const removed: string[] = []
ctx.on('subagent/provider-added', registered => void added.push(registered)) ctx.on('subagent/provider-added', registered => void added.push(registered))
@@ -184,6 +194,7 @@ describe('SubagentService', () => {
pluginCtx.subagents.registerProvider(provider) pluginCtx.subagents.registerProvider(provider)
}, },
}) })
expect(capabilityReads).toBe(1)
const accepted = ctx.subagents.getProvider('stable') const accepted = ctx.subagents.getProvider('stable')
const mutable = provider as unknown as { const mutable = provider as unknown as {
@@ -216,7 +227,10 @@ describe('SubagentService', () => {
expect(ctx.subagents.list()).toEqual(['stable']) expect(ctx.subagents.list()).toEqual(['stable'])
expect(ctx.subagents.getProvider('mutated')).toBeUndefined() expect(ctx.subagents.getProvider('mutated')).toBeUndefined()
const controller = new AbortController()
const run = ctx.subagents.start('stable', baseRequest({ const run = ctx.subagents.start('stable', baseRequest({
signal: controller.signal,
agentOptions: { model: 'mock' },
outputSchema: { type: 'object', properties: { answer: { type: 'string' } } }, outputSchema: { type: 'object', properties: { answer: { type: 'string' } } },
maxDepth: 2, maxDepth: 2,
toolFilter: { deny: ['bash'] }, toolFilter: { deny: ['bash'] },
@@ -277,6 +291,142 @@ describe('SubagentService', () => {
ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 })) ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 }))
expect(provider.startCount).toBe(1) expect(provider.startCount).toBe(1)
}) })
it.each([
{ label: 'NaN', value: Number.NaN },
{ label: 'a fraction', value: 1.5 },
{ label: 'a negative integer', value: -1 },
{ label: 'negative zero', value: -0 },
])('rejects maxDepth=$label before the provider starts', async ({ value }) => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('invalid-depth', ALL_CAPS)
ctx.subagents.registerProvider(provider)
expect(() => ctx.subagents.start('invalid-depth', baseRequest({ maxDepth: value })))
.toThrow('subagent maxDepth must be a non-negative safe integer')
expect(provider.startCount).toBe(0)
})
it('rejects a non-string persona before the provider starts', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('invalid-persona', { ...ALL_CAPS, persona: true })
ctx.subagents.registerProvider(provider)
expect(() => ctx.subagents.start('invalid-persona', baseRequest({
persona: 42 as unknown as string,
}))).toThrow('subagent persona must be a string')
expect(provider.startCount).toBe(0)
})
it('reads an optional capability accessor once so it cannot appear after validation', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
let accepted: SubagentStartRequest | undefined
const provider: SubagentProvider = {
name: 'weak-getter',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: (request) => {
accepted = request
return {
id: AgentId('weak-getter-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [], stopReason: 'completed' }),
cancel() {},
async dispose() {},
}
},
}
ctx.subagents.registerProvider(provider)
let reads = 0
const request = baseRequest()
Object.defineProperty(request, 'toolFilter', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? undefined : { deny: ['bash'] }
},
})
ctx.subagents.start('weak-getter', request)
expect(reads).toBe(1)
expect(accepted?.toolFilter).toBeUndefined()
})
})
it('rejects an exotic public prompt before the provider starts', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('prompt-boundary')
ctx.subagents.registerProvider(provider)
class ExoticTextBlock {
readonly type = 'text'
readonly text = 'hello'
}
expect(() => ctx.subagents.start('prompt-boundary', baseRequest({
prompt: [new ExoticTextBlock()] as unknown as SubagentStartRequest['prompt'],
}))).toThrow('subagent prompt must be losslessly JSON-serializable')
expect(provider.startCount).toBe(0)
})
it.each([
{
label: 'agent options',
overrides: { agentOptions: { model: Number.NaN as unknown as string } },
message: 'subagent agent options must be losslessly JSON-serializable',
},
{
label: 'tool filter',
overrides: { toolFilter: { deny: [Number.NaN as unknown as string] } },
message: 'subagent tool filter must be losslessly JSON-serializable',
},
{
label: 'output schema',
overrides: {
outputSchema: {
type: 'object',
properties: { answer: { type: Number.NaN } },
} as unknown as NonNullable<SubagentStartRequest['outputSchema']>,
},
message: 'schema annotation must be JSON data',
},
])('rejects non-JSON $label before the provider starts', async ({ overrides, message }) => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('invalid-request-data', ALL_CAPS)
ctx.subagents.registerProvider(provider)
expect(() => ctx.subagents.start('invalid-request-data', baseRequest(overrides)))
.toThrow(message)
expect(provider.startCount).toBe(0)
})
it('reads each nested prompt value once into the provider snapshot', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('unstable-prompt')
ctx.subagents.registerProvider(provider)
let reads = 0
const block = Object.defineProperties({}, {
type: { enumerable: true, value: 'text' },
text: {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? 'hello' : new Map([['not', 'json']])
},
},
})
expect(() => ctx.subagents.start('unstable-prompt', baseRequest({
prompt: [block] as unknown as SubagentStartRequest['prompt'],
}))).not.toThrow()
expect(reads).toBe(1)
expect(provider.startCount).toBe(1)
}) })
it('emits subagent/start then subagent/end around a run', async () => { it('emits subagent/start then subagent/end around a run', async () => {
@@ -299,6 +449,165 @@ describe('SubagentService', () => {
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
}) })
it('captures a provider run once and gives callers and telemetry one normalized result', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const reads = {
id: 0,
started: 0,
result: 0,
cancel: 0,
sendMessage: 0,
dispose: 0,
resume: 0,
output: 0,
structured: 0,
stopReason: 0,
}
const methodReceivers: string[] = []
const providerResult = Object.defineProperties({}, {
output: {
enumerable: true,
get: () => {
reads.output += 1
return reads.output === 1
? [{ type: 'text', text: 'accepted output' }]
: [{ type: 'text', text: 'drifted output' }]
},
},
structured: {
enumerable: true,
get: () => {
reads.structured += 1
return { verdict: reads.structured === 1 ? 'accepted' : 'drifted' }
},
},
stopReason: {
enumerable: true,
get: () => {
reads.stopReason += 1
return reads.stopReason === 1 ? 'completed' : 'error'
},
},
}) as SubagentResult
const providerRun = Object.defineProperties({}, {
id: {
enumerable: true,
get: () => {
reads.id += 1
return AgentId(reads.id === 1 ? 'accepted-child' : 'drifted-child')
},
},
started: {
enumerable: true,
get: () => {
reads.started += 1
if (reads.started !== 1) throw new Error('started reread')
return Promise.resolve()
},
},
result: {
enumerable: true,
get: () => {
reads.result += 1
if (reads.result !== 1) throw new Error('result reread')
return Promise.resolve(providerResult)
},
},
cancel: {
enumerable: true,
get: () => {
reads.cancel += 1
if (reads.cancel !== 1) throw new Error('cancel reread')
return function (this: SubagentRun): void {
expect(this).toBe(providerRun)
methodReceivers.push('cancel')
}
},
},
sendMessage: {
enumerable: true,
get: () => {
reads.sendMessage += 1
if (reads.sendMessage !== 1) throw new Error('sendMessage reread')
return function (this: SubagentRun): void {
expect(this).toBe(providerRun)
methodReceivers.push('sendMessage')
}
},
},
dispose: {
enumerable: true,
get: () => {
reads.dispose += 1
if (reads.dispose !== 1) throw new Error('dispose reread')
return async function (this: SubagentRun): Promise<void> {
expect(this).toBe(providerRun)
methodReceivers.push('dispose')
}
},
},
resume: {
enumerable: true,
get: () => {
reads.resume += 1
if (reads.resume !== 1) throw new Error('resume reread')
return function (this: SubagentRun): SubagentRun {
expect(this).toBe(providerRun)
methodReceivers.push('resume')
return providerRun
}
},
},
}) as SubagentRun
ctx.subagents.registerProvider({
name: 'stateful-run',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => providerRun,
})
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('stateful-run', baseRequest())
expect(Object.is(run, providerRun)).toBe(false)
expect(Object.isFrozen(run)).toBe(true)
run.cancel()
run.sendMessage?.([])
expect(Object.is(run.resume?.([]), providerRun)).toBe(true)
await run.dispose()
const result = await run.result
await run.started
await Promise.resolve()
expect(reads).toEqual({
id: 1,
started: 1,
result: 1,
cancel: 1,
sendMessage: 1,
dispose: 1,
resume: 1,
output: 1,
structured: 1,
stopReason: 1,
})
expect(methodReceivers).toEqual(['cancel', 'sendMessage', 'resume', 'dispose'])
expect(result).toEqual({
output: [{ type: 'text', text: 'accepted output' }],
structured: { verdict: 'accepted' },
stopReason: 'completed',
})
expect(Object.isFrozen(result)).toBe(true)
expect(Object.isFrozen(result.output)).toBe(true)
expect(ended).toHaveBeenCalledWith({
provider: 'stateful-run',
id: 'accepted-child',
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'accepted output' }],
})
})
it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => { it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
@@ -428,12 +737,13 @@ describe('SubagentService', () => {
})) }))
}) })
it('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => { it('observe-only: a mutating subagent/end listener cannot corrupt the caller or later listeners', async () => {
// The subagent/end emit fires from a detached `.then` registered before // The subagent/end emit fires from a detached `.then` registered before
// start() returns — i.e. BEFORE the caller's own `await run.result` // start() returns — i.e. BEFORE the caller's own `await run.result`
// continuation. If the event shared the result.output reference, a mutating // continuation. If the event shared the result.output reference, a mutating
// listener would change the SubagentResult the caller consumes. The service // listener would change the SubagentResult the caller consumes or the value
// deep-clones output onto the event, so the listener mutates only its copy. // a later observer sees. The service freezes one normalized result and the
// lifecycle payload before dispatching either public surface.
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider( ctx.subagents.registerProvider(new StubProvider(
@@ -448,12 +758,22 @@ describe('SubagentService', () => {
if (blocks?.[0]?.type === 'text') blocks[0].text = 'HIJACKED' if (blocks?.[0]?.type === 'text') blocks[0].text = 'HIJACKED'
blocks?.push({ type: 'text', text: 'injected' }) blocks?.push({ type: 'text', text: 'injected' })
}) })
const later = vi.fn()
ctx.on('subagent/end', later)
const run = ctx.subagents.start('clone', baseRequest()) const run = ctx.subagents.start('clone', baseRequest())
const result = await run.result const result = await run.result
await Promise.resolve() // let the detached settle hook (and its listener) run await Promise.resolve() // let the detached settle hook (and its listener) run
// The caller's result.output is untouched by the listener's mutation. // The caller and the listener after the mutator both retain the accepted value.
expect(result.output).toEqual([{ type: 'text', text: 'original' }]) expect(result.output).toEqual([{ type: 'text', text: 'original' }])
expect(Object.isFrozen(result.output)).toBe(true)
expect(later).toHaveBeenCalledWith(expect.objectContaining({
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'original' }],
}))
const laterInfo = later.mock.calls[0]![0] as Record<string, unknown>
expect(Object.isFrozen(laterInfo)).toBe(true)
expect(Object.isFrozen(laterInfo.lastAssistantMessage)).toBe(true)
}) })
it('omits lastAssistantMessage on the reject path (no SubagentResult was produced)', async () => { it('omits lastAssistantMessage on the reject path (no SubagentResult was produced)', async () => {
@@ -483,17 +803,14 @@ describe('SubagentService', () => {
expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject
}) })
it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => { it('rejects an invalid provider output and maps the contract fault to error telemetry', async () => {
// The clone runs inside onFulfilled, OUTSIDE emitLifecycle's per-listener // A function is outside the lossless JSON vocabulary. The service-owned
// containment. An uncloneable output (here a content block carrying a // result promise rejects instead of exposing the malformed provider value;
// function) would otherwise throw and become an unhandled rejection on the // its already-attached lifecycle observer maps that infrastructure fault to
// detached `.then`. The handler must instead log and emit the event WITHOUT // error telemetry without producing an unhandled rejection.
// lastAssistantMessage, still carrying the real stopReason.
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
const warn = vi.fn(); ctx.logger.warn = warn as never const nonJsonOutput = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output']
// An output value structuredClone cannot handle (a function is uncloneable).
const uncloneable = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output']
ctx.subagents.registerProvider({ ctx.subagents.registerProvider({
name: 'unclone', name: 'unclone',
capabilities: NO_CAPS, capabilities: NO_CAPS,
@@ -501,7 +818,7 @@ describe('SubagentService', () => {
start: () => ({ start: () => ({
id: AgentId('unclone-child'), id: AgentId('unclone-child'),
started: Promise.resolve(), started: Promise.resolve(),
result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult), result: Promise.resolve({ output: nonJsonOutput, stopReason: 'completed' } as SubagentResult),
cancel() {}, cancel() {},
dispose: async () => {}, dispose: async () => {},
}), }),
@@ -510,13 +827,52 @@ describe('SubagentService', () => {
const ended = vi.fn() const ended = vi.fn()
ctx.on('subagent/end', ended) ctx.on('subagent/end', ended)
const run = ctx.subagents.start('unclone', baseRequest()) const run = ctx.subagents.start('unclone', baseRequest())
await run.result await expect(run.result).rejects.toThrow('subagent result must be losslessly JSON-serializable')
await Promise.resolve() await Promise.resolve()
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown> const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved expect(endInfo.stopReason).toBe('error')
expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed expect('lastAssistantMessage' in endInfo).toBe(false)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone')) })
it.each([
{
label: 'a non-array output',
value: { output: { type: 'text', text: 'not an array' }, stopReason: 'completed' },
message: 'subagent result output must be an array',
},
{
label: 'a non-string stopReason',
value: { output: [], stopReason: 42 },
message: 'subagent result stopReason must be a string',
},
])('rejects a provider result with $label', async ({ value, message }) => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'invalid-shape',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('invalid-shape-child'),
started: Promise.resolve(),
result: Promise.resolve(value as unknown as SubagentResult),
cancel() {},
async dispose() {},
}),
})
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('invalid-shape', baseRequest())
await expect(run.result).rejects.toThrow(message)
await Promise.resolve()
expect(ended).toHaveBeenCalledWith(expect.objectContaining({
provider: 'invalid-shape',
id: 'invalid-shape-child',
stopReason: 'error',
}))
}) })
it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => { it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => {
@@ -566,6 +922,60 @@ describe('SubagentService', () => {
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
}) })
it('contains a listener whose thrown value cannot be stringified', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider('hostile-listener'))
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const hostile = {
[Symbol.toPrimitive]() { throw new Error('render failed') },
}
const second = vi.fn()
ctx.on('subagent/start', () => { throw hostile })
ctx.on('subagent/start', second)
const run = ctx.subagents.start('hostile-listener', baseRequest())
await run.started
expect(second).toHaveBeenCalledOnce()
expect(warnings.some(message => message.includes('<unrenderable thrown value>'))).toBe(true)
await run.result
})
it('rejects a throwing provider result accessor and maps it to error telemetry', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'hostile-result',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('hostile-result-child'),
started: Promise.resolve(),
result: Promise.resolve({
output: [],
get stopReason(): 'completed' { throw new Error('stop reason exploded') },
}),
cancel() {},
async dispose() {},
}),
})
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('hostile-result', baseRequest())
await run.started
await expect(run.result).rejects.toThrow('stop reason exploded')
await Promise.resolve()
expect(ended).toHaveBeenCalledWith(expect.objectContaining({
provider: 'hostile-result',
id: 'hostile-result-child',
stopReason: 'error',
}))
})
it('contains a throwing subagent/end listener per-listener: a later listener still observes the settle, no unhandled rejection', async () => { it('contains a throwing subagent/end listener per-listener: a later listener still observes the settle, no unhandled rejection', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)

View File

@@ -17,6 +17,9 @@
{ {
"path": "../../core/agent" "path": "../../core/agent"
}, },
{
"path": "../../core/session"
},
{ {
"path": "../../llm/llm" "path": "../../llm/llm"
}, },

View File

@@ -5,7 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than
| Package | Role | ctx key | | Package | Role | ctx key |
|---|---|---| |---|---|---|
| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | | `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) |
| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | | `invariants/` | Dev-mode event-contract assertions | (listens on `session/*`, `agent/*`) |
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
| `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) |

View File

@@ -1,9 +1,11 @@
# dsh-invariants # dsh-invariants
Dev-mode event-contract invariants and session-log freeze. A pure-listener plugin (everything is a plugin) that asserts the harness event contract at runtime and, optionally, freezes logged session-event data so any code that mutates history throws instead of corrupting silently. Dev-mode event-contract assertions. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests at runtime; it does not own or change product behavior.
**Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract. **Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express.
## Plugin ## Plugin
A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does): A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does):
@@ -14,17 +16,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants'
declare const ctx: Context declare const ctx: Context
await ctx.plugin(Invariants) // freeze on (default) await ctx.plugin(Invariants)
await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freeze
``` ```
`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist (so a hot reload mid-turn doesn't falsely reject the next event). It listens on `session/created`, `session/event`, and `agent/status`. `inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist, so a hot reload mid-turn does not falsely reject the next event. It registers only listeners and has no configuration.
### Config
| Key | Default | Meaning |
|---|---|---|
| `freeze` | `true` | Deep-freeze each logged event's data so mutating a logged event throws. Set `false` to assert the contract without freezing. |
## Invariants asserted ## Invariants asserted
@@ -46,10 +41,10 @@ Model requests (on `llm/stream`):
On any violation it throws `InvariantError` (`code: 'INVARIANT'`). On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
## Why runtime, not deep-readonly types ## Why runtime assertions remain useful
A `DeepReadonly<SessionEvent>` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly<SessionEvent>` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships in development while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
## Seeded sessions ## Seeded sessions
A seeded/forked session arrives with events already in its log (the `Session` constructor copies the seed without emitting `session/event`). On `session/created` the plugin replays the existing log through the checker and freezes those entries, so seeded history is held to the same contract. A seeded or forked session arrives with events already in its log because construction does not emit `session/event` for each seed record. `Session` validates, snapshots, and freezes every seed record before accepting it; on `session/created`, this plugin replays the accepted log only to rebuild and check its relational trace state.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@deepseek-ai/dsh-invariants", "name": "@deepseek-ai/dsh-invariants",
"description": "Dev-mode event-contract invariants + session-log freeze for the DeepSeek Harness", "description": "Dev-mode event-contract assertions for the DeepSeek Harness",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@@ -1,20 +1,18 @@
/** /**
* Dev-mode invariants: a pure-listener plugin that asserts the harness event * Dev-mode invariants: a pure-listener plugin that asserts relationships in
* contract at runtime, and (optionally) freezes logged session-event data so * the harness event contract at runtime.
* any code that mutates history throws instead of corrupting silently.
* *
* Everything is a plugin — this is just listeners on `session/created`, * Everything is a plugin — this is just listeners on `session/created`,
* `session/event`, and `agent/status`. It is **off in production**: enable it * `session/event`, `agent/status`, and the scoped dispatch and request seams.
* in tests and the demos, where a contract violation should be a loud failure, * It is **off in production**: enable it in tests and demos, where a contract
* not a subtle one. It doubles as executable documentation of the event * violation should be a loud failure rather than a subtle one. It doubles as
* taxonomy: the assertions below ARE the contract. * executable documentation of the event taxonomy: the assertions below are
* the contract.
* *
* Why runtime assertions instead of compile-time deep-readonly types? See * Session owns immutable log storage: it snapshots and deep-freezes every
* the dev-invariants RFC. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across * accepted event at the source. This plugin checks relationships that one
* every log consumer and a plugin casts straight through it; a dev-mode freeze * event's types and immutability cannot express, including turn/step nesting,
* + assertions catch real corruption at zero production cost and zero type * scoped dispatch, status transitions, and request reconstructability.
* noise. The always-on half of that defense (cloning derived messages) lives
* in dsh-session; this package is the dev-mode tripwire.
* *
* @module @deepseek-ai/dsh-invariants * @module @deepseek-ai/dsh-invariants
*/ */
@@ -44,16 +42,6 @@ export class InvariantError extends HarnessError {
} }
} }
/** Plugin config. */
export interface Config {
/**
* Deep-freeze logged session-event data so mutating a logged event throws.
* Default true — this plugin only runs in dev/test, where freezing is the
* point. Set false to assert the event contract without freezing.
*/
freeze?: boolean
}
/** Per-session bookkeeping for the session-log invariants. */ /** Per-session bookkeeping for the session-log invariants. */
interface SessionTrace { interface SessionTrace {
/** Highest `seq` seen so far (must strictly increase). */ /** Highest `seq` seen so far (must strictly increase). */
@@ -87,29 +75,6 @@ interface AgentSubject {
agent: Agent agent: Agent
} }
/**
* Deep-freeze a value and everything reachable from it.
*
* Walks every object's own properties even when the object itself is already
* frozen: `Session.append()` accepts event data from arbitrary plugins/tools,
* so a caller can hand us a SHALLOW-frozen object whose descendants are still
* mutable. Skipping an already-frozen node (the obvious idempotence shortcut)
* would leave exactly the kind of mutable history the dev-invariants RFC means to catch. A
* `WeakSet` of visited objects keeps it terminating on cycles and avoids
* re-walking shared subtrees / already-processed seed events.
*/
function deepFreeze(value: unknown, seen: WeakSet<object> = new WeakSet()): void {
if (value === null || typeof value !== 'object') return
if (seen.has(value)) return
seen.add(value)
// Freeze the node (no-op if a caller pre-froze it), then ALWAYS descend —
// a frozen container can still hold mutable children.
Object.freeze(value)
for (const key of Object.keys(value)) {
deepFreeze((value as Record<string, unknown>)[key], seen)
}
}
/** Assert that a step-scoped event names the currently open turn and step. */ /** Assert that a step-scoped event names the currently open turn and step. */
function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void { function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void {
if (trace.openTurn !== turn || trace.openStep !== step) { if (trace.openTurn !== turn || trace.openStep !== step) {
@@ -311,13 +276,13 @@ function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
/** /**
* Register the dev-mode invariants. Contributions are effect-scoped, so * Register the dev-mode invariants. Contributions are effect-scoped, so
* disposing the plugin fiber removes all listeners and stops freezing * disposing the plugin fiber removes all listeners (HMR-safe). On (re-)apply
* (HMR-safe). On (re-)apply the trace state is rebuilt by replaying each * the trace state is rebuilt by replaying each existing session's log, so a
* existing session's log, so a hot reload mid-turn does not falsely reject the * hot reload mid-turn does not falsely reject the next event.
* next event. *
* @param ctx - Cordis context that receives the invariant listeners.
*/ */
export function apply(ctx: Context, config: Config = {}): void { export function apply(ctx: Context): void {
const freeze = config.freeze ?? true
const traces = new WeakMap<Session, SessionTrace>() const traces = new WeakMap<Session, SessionTrace>()
// Agent status has no stored history to replay; the first observation after // Agent status has no stored history to replay; the first observation after
// (re-)apply seeds the baseline, so a reload never produces a false positive. // (re-)apply seeds the baseline, so a reload never produces a false positive.
@@ -334,13 +299,12 @@ export function apply(ctx: Context, config: Config = {}): void {
surface: [], surface: [],
}) })
/** Build (or rebuild) a session's trace by replaying its whole log; freeze it. */ /** Build (or rebuild) a session's trace by replaying its whole log. */
const seedSession = (session: Session): SessionTrace => { const seedSession = (session: Session): SessionTrace => {
const trace = freshTrace() const trace = freshTrace()
traces.set(session, trace) traces.set(session, trace)
for (const event of session.events) { for (const event of session.events) {
checkEvent(trace, event) checkEvent(trace, event)
if (freeze) deepFreeze(event)
} }
return trace return trace
} }
@@ -362,7 +326,6 @@ export function apply(ctx: Context, config: Config = {}): void {
ctx.on('session/event', (session, event) => { ctx.on('session/event', (session, event) => {
checkEvent(traceFor(session), event) checkEvent(traceFor(session), event)
if (freeze) deepFreeze(event)
}) })
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', (agent, status) => {

View File

@@ -8,10 +8,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants'
import { InvariantError } from '@deepseek-ai/dsh-invariants' import { InvariantError } from '@deepseek-ai/dsh-invariants'
/** A Context with the session store and the invariants plugin registered. */ /** A Context with the session store and the invariants plugin registered. */
async function setup(config?: { freeze?: boolean }) { async function setup() {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(Invariants, config ?? {}) const fiber = await ctx.plugin(Invariants)
return { ctx, fiber } return { ctx, fiber }
} }
@@ -22,7 +22,7 @@ function mockAgent(id: string): Agent {
describe('session-log invariants', () => { describe('session-log invariants', () => {
it('accepts a well-formed turn/step/tool sequence', async () => { it('accepts a well-formed turn/step/tool sequence', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
expect(() => { expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -38,7 +38,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a non-monotonic seq (replay spine)', async () => { it('rejects a non-monotonic seq (replay spine)', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
// Session.append enforces seq-contiguity at the source, so drive the // Session.append enforces seq-contiguity at the source, so drive the
// invariants seq check directly via session/event with a regressing seq. // invariants seq check directly via session/event with a regressing seq.
@@ -48,7 +48,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a turn/start while another turn is open', async () => { it('rejects a turn/start while another turn is open', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
@@ -56,7 +56,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a turn/end that does not match the open turn', async () => { it('rejects a turn/end that does not match the open turn', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })) expect(() => session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
@@ -64,14 +64,14 @@ describe('session-log invariants', () => {
}) })
it('rejects a step/start outside its declared turn', async () => { it('rejects a step/start outside its declared turn', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/) expect(() => session.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
}) })
it('rejects a step/end that does not match the open step', async () => { it('rejects a step/end that does not match the open step', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 1 })
@@ -79,7 +79,7 @@ describe('session-log invariants', () => {
}) })
it('rejects an assistant/chunk outside an open step', async () => { it('rejects an assistant/chunk outside an open step', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } })) expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }))
@@ -87,7 +87,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a message event appended outside any open turn (turn-enclosure)', async () => { it('rejects a message event appended outside any open turn (turn-enclosure)', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
// No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC). // No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC).
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
@@ -97,7 +97,7 @@ describe('session-log invariants', () => {
}) })
it('rejects steering and plugin-added events appended outside any open turn', async () => { it('rejects steering and plugin-added events appended outside any open turn', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
// steering/message is turn-scoped: outside a turn it would land past the // steering/message is turn-scoped: outside a turn it would land past the
// commit boundary and be dropped on resume (the turn-enclosure RFC). // commit boundary and be dropped on resume (the turn-enclosure RFC).
@@ -113,7 +113,7 @@ describe('session-log invariants', () => {
}) })
it('accepts message events once a turn is open', async () => { it('accepts message events once a turn is open', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
@@ -121,7 +121,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a tool/result with no prior tool/call', async () => { it('rejects a tool/result with no prior tool/call', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 1 })
@@ -130,7 +130,7 @@ describe('session-log invariants', () => {
}) })
it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => { it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
expect(() => { expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -152,7 +152,7 @@ describe('session-log invariants', () => {
}) })
it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => { it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
expect(() => { expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -164,7 +164,7 @@ describe('session-log invariants', () => {
}) })
it('holds seeded sessions to the contract on session/created', async () => { it('holds seeded sessions to the contract on session/created', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
// A seq-contiguous, serializable seed (so it passes Session's constructor // A seq-contiguous, serializable seed (so it passes Session's constructor
// validation) that nonetheless violates turn nesting — a second turn/start // validation) that nonetheless violates turn nesting — a second turn/start
// while the first turn is still open — must be rejected by the invariants // while the first turn is still open — must be rejected by the invariants
@@ -177,7 +177,7 @@ describe('session-log invariants', () => {
}) })
it('tracks turns per session independently', async () => { it('tracks turns per session independently', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const a = ctx.sessions.create(SessionId('a')) const a = ctx.sessions.create(SessionId('a'))
const b = ctx.sessions.create(SessionId('b')) const b = ctx.sessions.create(SessionId('b'))
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -186,7 +186,7 @@ describe('session-log invariants', () => {
}) })
it('accepts multiple steps in a turn and consecutive turns', async () => { it('accepts multiple steps in a turn and consecutive turns', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
expect(() => { expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -203,7 +203,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a skipped turn number', async () => { it('rejects a skipped turn number', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -212,7 +212,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a skipped step number within a turn', async () => { it('rejects a skipped step number within a turn', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 1 })
@@ -222,7 +222,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a turn/end while a step is still open', async () => { it('rejects a turn/end while a step is still open', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 1 })
@@ -231,7 +231,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a step/start while a step is still open', async () => { it('rejects a step/start while a step is still open', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 1 })
@@ -239,7 +239,7 @@ describe('session-log invariants', () => {
}) })
it('rejects a tool/result satisfying a call from a previous step', async () => { it('rejects a tool/result satisfying a call from a previous step', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 1 })
@@ -252,7 +252,7 @@ describe('session-log invariants', () => {
}) })
it('rejects an assistant/message naming the wrong step', async () => { it('rejects an assistant/message naming the wrong step', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 1 })
@@ -266,7 +266,7 @@ describe('HMR state rebuild', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
// First registration, mid-turn: a turn is open when the plugin reloads. // First registration, mid-turn: a turn is open when the plugin reloads.
const first = await ctx.plugin(Invariants, { freeze: false }) const first = await ctx.plugin(Invariants)
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 1 })
@@ -274,7 +274,7 @@ describe('HMR state rebuild', () => {
// Re-apply (HMR): the fresh fiber must replay the existing log so the open // Re-apply (HMR): the fresh fiber must replay the existing log so the open
// step is known — the next chunk must NOT be a false positive. // step is known — the next chunk must NOT be a false positive.
await ctx.plugin(Invariants, { freeze: false }) await ctx.plugin(Invariants)
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })) expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }))
.not.toThrow() .not.toThrow()
// And a genuine violation is still caught after the rebuild. // And a genuine violation is still caught after the rebuild.
@@ -283,76 +283,49 @@ describe('HMR state rebuild', () => {
}) })
}) })
describe('dev-freeze', () => { describe('session immutability', () => {
it('freezes appended event data so mutating a logged event throws', async () => { it('always freezes appended event data without the invariants plugin', () => {
const { ctx } = await setup() // freeze defaults true const session = new Session(SessionId('appended'))
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(Object.isFrozen(event)).toBe(true) expect(Object.isFrozen(event)).toBe(true)
expect(Object.isFrozen(event.data)).toBe(true) expect(Object.isFrozen(event.data)).toBe(true)
expect(Object.isFrozen(event.data.content)).toBe(true) expect(Object.isFrozen(event.data.content)).toBe(true)
expect(Object.isFrozen(event.data.content[0])).toBe(true)
expect(Object.isFrozen(session.events)).toBe(true)
expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow() expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow()
}) })
it('does not freeze when freeze:false', async () => { it('always freezes seeded events without the invariants plugin', () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(Object.isFrozen(event)).toBe(false)
})
it('freezes seeded events on session/created', async () => {
const { ctx } = await setup()
const seed = [ const seed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const }, { type: 'user/message' as const, seq: 1, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
] ]
const session = ctx.sessions.create(undefined, { seed }) const session = new Session(SessionId('seeded'), seed)
expect(Object.isFrozen(seed[0])).toBe(false)
expect(Object.isFrozen(session.events)).toBe(true)
expect(Object.isFrozen(session.events[0])).toBe(true) expect(Object.isFrozen(session.events[0])).toBe(true)
expect(Object.isFrozen(session.events[0]?.data)).toBe(true)
expect(Object.isFrozen(session.events[1]?.data)).toBe(true)
}) })
it('freezes mutable descendants of a shallow-frozen event datum', async () => { it('snapshots and freezes descendants of a shallow-frozen caller value', () => {
const { ctx } = await setup() const session = new Session(SessionId('shallow-frozen'))
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// A caller hands in a SHALLOW-frozen block whose nested array is still
// mutable. deepFreeze must descend into the already-frozen object and
// freeze the descendant, not short-circuit on the frozen container —
// otherwise dev-mode misses exactly the history mutation the dev-invariants RFC catches.
// `append` snapshots `data`, so the freeze applies to the LOGGED clone, not
// the caller's input — read the event back and assert on its data.
const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }] const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }]
const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false }) const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false })
const event = session.append('user/message', { content: [block], source: { kind: 'user' } }, { surfaceOp: 'append' }) const event = session.append('user/message', { content: [block], source: { kind: 'user' } }, { surfaceOp: 'append' })
const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] } const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] }
expect(Object.isFrozen(innerContent)).toBe(false)
expect(Object.isFrozen(logged.content)).toBe(true) expect(Object.isFrozen(logged.content)).toBe(true)
expect(Object.isFrozen(logged.content[0])).toBe(true) expect(Object.isFrozen(logged.content[0])).toBe(true)
innerContent[0]!.text = 'caller mutation'
expect(logged.content[0]!.text).toBe('inner')
expect(() => { logged.content.push({ type: 'text', text: 'mutation' }) }).toThrow() expect(() => { logged.content.push({ type: 'text', text: 'mutation' }) }).toThrow()
}) })
it('terminates on a cyclic event datum (WeakSet guard)', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
// The deep-freeze WeakSet guard must terminate on a self-referential
// structure rather than recursing forever. Session.append now rejects
// non-serializable (incl. cyclic) data at the source, so drive the freeze
// handler directly via hand-built session/events — exactly the shape the
// invariants listener receives. Open a turn first (seq 0) so the cyclic
// user/message (seq 1) satisfies the turn-enclosure invariant.
ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
cyclic['self'] = cyclic
const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } }
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }).not.toThrow()
expect(Object.isFrozen(cyclic)).toBe(true)
})
}) })
describe('agent status invariants', () => { describe('agent status invariants', () => {
it('accepts legal transitions: idle→running→idle and →disposed', async () => { it('accepts legal transitions: idle→running→idle and →disposed', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const agent = mockAgent('a1') const agent = mockAgent('a1')
expect(() => { expect(() => {
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
@@ -363,28 +336,28 @@ describe('agent status invariants', () => {
}) })
it('accepts running→disposed', async () => { it('accepts running→disposed', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const agent = mockAgent('a2') const agent = mockAgent('a2')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow() expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow()
}) })
it('rejects a no-op transition', async () => { it('rejects a no-op transition', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const agent = mockAgent('a3') const agent = mockAgent('a3')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }).toThrow(/no-op transition/) expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }).toThrow(/no-op transition/)
}) })
it('rejects leaving the terminal disposed state', async () => { it('rejects leaving the terminal disposed state', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const agent = mockAgent('a4') const agent = mockAgent('a4')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/) expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/)
}) })
it('tracks status per agent independently', async () => { it('tracks status per agent independently', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const a = mockAgent('a5') const a = mockAgent('a5')
const b = mockAgent('b5') const b = mockAgent('b5')
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running')
@@ -401,10 +374,10 @@ describe('HMR safety', () => {
await fiber.dispose() await fiber.dispose()
// After disposal: no freezing, no assertions. An event that WOULD have // After disposal the plugin's assertions are gone, so an event that would
// violated the open-turn rule now passes silently, and is not frozen. // violate the open-turn rule passes. Session still owns immutability.
const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(Object.isFrozen(event)).toBe(false) expect(Object.isFrozen(event)).toBe(true)
// A no-op status transition no longer throws either. // A no-op status transition no longer throws either.
const agent = mockAgent('hmr') const agent = mockAgent('hmr')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
@@ -419,16 +392,17 @@ describe('HMR safety', () => {
expect(err.message).toBe('invariant violated: seq must strictly increase') expect(err.message).toBe('invariant violated: seq must strictly increase')
}) })
it('does not leak listeners across dispose (no stale freezing)', async () => { it('does not leak listeners across dispose', async () => {
const { ctx, fiber } = await setup() const { ctx, fiber } = await setup()
await fiber.dispose() await fiber.dispose()
const spy = vi.fn() const spy = vi.fn()
ctx.on('session/event', spy) ctx.on('session/event', spy)
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
// our own spy fires, proving events still flow — but the plugin's frozen. // The spy proves events still flow after plugin disposal. Session, not the
// disposed listener, freezes the accepted record.
expect(spy).toHaveBeenCalledOnce() expect(spy).toHaveBeenCalledOnce()
expect(Object.isFrozen(session.events[0])).toBe(false) expect(Object.isFrozen(session.events[0])).toBe(true)
}) })
}) })
@@ -640,7 +614,7 @@ describe('surface invariants', () => {
}) })
it('catches an incomplete-provenance replace on the load/seed path', async () => { it('catches an incomplete-provenance replace on the load/seed path', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const badSeed = [ const badSeed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } }, { type: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } },
@@ -655,10 +629,10 @@ describe('surface invariants', () => {
const { ctx } = await setup() const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// Type system prevents surface metadata on non-surface events; this test // Session rejects this at its own acceptance boundary. Emit a hand-built
// exercises the runtime guard against casts or persisted-data bypass. // record to cover the listener's defensive check for alternate producers.
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, sourceEventSeqs: [0] }
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { sourceEventSeqs: [0] })) expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
.toThrow(/cannot carry sourceEventSeqs/) .toThrow(/cannot carry sourceEventSeqs/)
}) })
@@ -666,8 +640,8 @@ describe('surface invariants', () => {
const { ctx } = await setup() const { ctx } = await setup()
const session = ctx.sessions.create() const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, surfaceOp: 'append' }
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { surfaceOp: 'append' })) expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
.toThrow(/cannot carry surfaceOp/) .toThrow(/cannot carry surfaceOp/)
}) })
}) })
@@ -675,7 +649,7 @@ describe('surface invariants', () => {
describe('request-reconstruction cross-check (llm/stream)', () => { describe('request-reconstruction cross-check (llm/stream)', () => {
/** Session with a boundary: one derivable user message, an open step, and the header event the loop would have logged. */ /** Session with a boundary: one derivable user message, an open step, and the header event the loop would have logged. */
async function requestSetup() { async function requestSetup() {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create(SessionId('req-check')) const session = ctx.sessions.create(SessionId('req-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
@@ -737,7 +711,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
}) })
it('rejects a loop-built request with no header event or no step/start in its log', async () => { it('rejects a loop-built request with no header event or no step/start in its log', async () => {
const { ctx } = await setup({ freeze: false }) const { ctx } = await setup()
const session = ctx.sessions.create(SessionId('req-bare')) const session = ctx.sessions.create(SessionId('req-bare'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id }) const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
@@ -778,7 +752,7 @@ describe('request cross-check ordering (prepend)', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next() ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next()
await ctx.plugin(Invariants, { freeze: false }) await ctx.plugin(Invariants)
const session = ctx.sessions.create(SessionId('prepend-check')) const session = ctx.sessions.create(SessionId('prepend-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })

View File

@@ -57,7 +57,8 @@ describe('dsh-subagent-mock', () => {
// the structured path is only reachable when the cap is on; with it off and // the structured path is only reachable when the cap is on; with it off and
// no schema requested, the result has no structured field. // no schema requested, the result has no structured field.
const run = ctx.subagents.start('mock', baseRequest()) const run = ctx.subagents.start('mock', baseRequest())
await expect(run.result).resolves.toMatchObject({ structured: undefined }) const result = await run.result
expect(result).not.toHaveProperty('structured')
}) })
it('honors a configured stop reason', async () => { it('honors a configured stop reason', async () => {

View File

@@ -35,9 +35,9 @@ Values LEAVING the script (hook options/schemas, the script's return) are materi
## Cancellation, death, disposal ## Cancellation, death, disposal
Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`. Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). Each provider-owned cancel callback is exception-contained independently, so a broken child cannot prevent peer cancellation or workflow settlement. The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`.
A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Once a run settles, stray children a script fired without awaiting are cancelled too, and `dispose()` waits for their disposal (bounded by the grace) before returning. `agent-start`/`agent-end` pairing is host-guaranteed the same way: forwarded starts live in a ledger, worker-reported ends pair them on the graceful paths, and the termination paths (grace force-settle, worker death) synthesize the missing ends (outcome `cancelled`) before the run settles — a start still in flight across the force-settle can surface after `workflow/end`, immediately paired the same way. A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Before an ordinary run settlement becomes observable, the host cancels every stray child on both channels too—even a fire-and-forget run still waiting on `started`, for which the worker has no handle yet—and `dispose()` then waits for their disposal (bounded by the grace) before returning. `agent-start`/`agent-end` pairing is host-guaranteed the same way: forwarded starts live in a ledger, worker-reported ends pair them on the graceful paths, and the termination paths (grace force-settle, worker death) synthesize the missing ends (outcome `cancelled`) before the run settles — a start still in flight across the force-settle can surface after `workflow/end`, immediately paired the same way.
**Engine-specific limitations**: worker startup is paid per run; on a termination path `agentsStarted` reports the HOST-observed count (accepted `child-start`s — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — with the value-boundary guard applying to the resolution. **Engine-specific limitations**: worker startup is paid per run; on a termination path `agentsStarted` reports the HOST-observed count (accepted `child-start`s — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — with the value-boundary guard applying to the resolution.

View File

@@ -30,6 +30,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-workflow": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1",

View File

@@ -15,10 +15,13 @@
* terminated — the real kill an in-process engine could not perform). * terminated — the real kill an in-process engine could not perform).
* *
* Children live in a host-side registry (callId → run) as soon as the provider * Children live in a host-side registry (callId → run) as soon as the provider
* accepts them, so cancellation reaches even a pre-publication attempt. The * accepts them, so cancellation reaches even a pre-publication attempt. Both
* host observes `result` immediately but acknowledges the child to the worker * explicit run cancellation and the shared request signal are driven when the
* only after `started` fulfills; readiness failure is a start error and the * workflow is cancelled OR normally settles, so a fire-and-forget child cannot
* host disposes the attempt because the worker never received a handle. The * survive merely by honoring only one channel. The host observes `result`
* immediately but acknowledges the child to the worker only after `started`
* fulfills; readiness failure is a start error and the host disposes the
* attempt because the worker never received a handle. The
* worker drives disposal by RPC on the graceful path, `dispose()` host-drives * worker drives disposal by RPC on the graceful path, `dispose()` host-drives
* every registered child's disposal immediately (a wedged worker can relay no * every registered child's disposal immediately (a wedged worker can relay no
* dispose RPC, and child teardown must overlap the grace, not start after it), * dispose RPC, and child teardown must overlap the grace, not start after it),
@@ -44,6 +47,7 @@ import type { WorkerOptions } from 'node:worker_threads'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
import { assertNever } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { SubagentRun } from '@deepseek-ai/dsh-subagent' import type { SubagentRun } from '@deepseek-ai/dsh-subagent'
import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow' import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import { renderThrown } from './realm.ts' import { renderThrown } from './realm.ts'
@@ -180,11 +184,10 @@ export class WorkerRun implements WorkflowRun {
if (this.settled || this.cancelReason !== undefined) return if (this.settled || this.cancelReason !== undefined) return
this.cancelReason = reason ?? 'workflow cancelled' this.cancelReason = reason ?? 'workflow cancelled'
this.post(HostToWorkerType.Cancel, { reason: this.cancelReason }) this.post(HostToWorkerType.Cancel, { reason: this.cancelReason })
this.controller.abort(this.cancelReason)
// The explicit channel is driven host-side, not left to the worker: a // The explicit channel is driven host-side, not left to the worker: a
// provider honoring only run.cancel() must not wait on a wedged worker's // provider honoring only run.cancel() must not wait on a wedged worker's
// ChildCancel relay (those later RPCs land as idempotent no-ops). // ChildCancel relay (those later RPCs land as idempotent no-ops).
for (const run of this.children.values()) run.cancel(this.cancelReason) this.cancelChildren(this.cancelReason)
this.graceTimer = setTimeout(() => { this.graceTimer = setTimeout(() => {
// The worker may no longer speak (it is about to be terminated): pair // The worker may no longer speak (it is about to be terminated): pair
// every stranded start before the run settles, so ends precede // every stranded start before the run settles, so ends precede
@@ -274,7 +277,10 @@ export class WorkerRun implements WorkflowRun {
this.onChildStart(message.callId, message.request) this.onChildStart(message.callId, message.request)
break break
case WorkerToHostType.ChildCancel: case WorkerToHostType.ChildCancel:
this.children.get(message.callId)?.cancel(message.reason) {
const run = this.children.get(message.callId)
if (run !== undefined) this.cancelChild(run, message.reason)
}
break break
case WorkerToHostType.ChildDispose: case WorkerToHostType.ChildDispose:
this.onChildDispose(message.callId) this.onChildDispose(message.callId)
@@ -322,11 +328,19 @@ export class WorkerRun implements WorkflowRun {
const forwardResult = run.result.then<() => void, () => void>( const forwardResult = run.result.then<() => void, () => void>(
(result) => { (result) => {
try { try {
const snapshot: ChildResult = structuredClone({ // Capture every provider-owned field once, then materialize the
output: result.output, // worker-bound value in one lossless traversal. A stateful accessor
...result.structured !== undefined ? { structured: result.structured } : {}, // cannot validate one result and send another, and an exotic value is
stopReason: result.stopReason, // rejected before any prototype-erasing clone.
const output = result.output
const structured = result.structured
const stopReason = result.stopReason
const snapshot = snapshotJsonValue<ChildResult>({
output,
...structured !== undefined ? { structured } : {},
stopReason,
}) })
if (snapshot === undefined) throw new TypeError('child result is not losslessly JSON-serializable')
return () => { this.post(HostToWorkerType.ChildSettled, { callId, result: snapshot }) } return () => { this.post(HostToWorkerType.ChildSettled, { callId, result: snapshot }) }
} catch (error: unknown) { } catch (error: unknown) {
const rendered = `workflow child result could not cross the worker boundary: ${renderThrown(error)}` const rendered = `workflow child result could not cross the worker boundary: ${renderThrown(error)}`
@@ -416,18 +430,34 @@ export class WorkerRun implements WorkflowRun {
/** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */ /** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */
private reapChildren(reason: string): void { private reapChildren(reason: string): void {
this.controller.abort(this.cancelReason ?? reason) const cancellation = this.cancelReason ?? reason
this.cancelChildren(cancellation)
for (const [callId, run] of [...this.children]) { for (const [callId, run] of [...this.children]) {
run.cancel(this.cancelReason ?? reason)
void this.disposeChild(callId, run) void this.disposeChild(callId, run)
} }
} }
/** Drive both cancellation channels for every child already accepted by the host. */
private cancelChildren(reason: string): void {
this.controller.abort(reason)
for (const run of this.children.values()) this.cancelChild(run, reason)
}
/** Contain one provider-owned cancel callback so every peer still receives cancellation. */
private cancelChild(run: SubagentRun, reason?: string): void {
try {
run.cancel(reason)
} catch (error: unknown) {
this.ctx.logger.warn(`workflow-workerthread: child cancel failed: ${renderThrown(error)}`)
}
}
private onResult(result: WorkflowResult): void { private onResult(result: WorkflowResult): void {
// The worker's settle-reap already child-cancel()s every stray; this // The worker cancels handles it already received, but a fire-and-forget
// abort fires the seam signal too, for providers that only honor the // child may still be waiting on readiness and therefore have no worker
// request signal (both channels, on every path). // handle. Drive BOTH provider-permitted channels from the host before the
if (this.cancelReason === undefined) this.controller.abort('workflow settled') // workflow becomes externally settled.
if (this.cancelReason === undefined) this.cancelChildren('workflow settled')
if (this.cancelReason !== undefined && result.stopReason !== 'cancelled') { if (this.cancelReason !== undefined && result.stopReason !== 'cancelled') {
// The script settled while our cancel was crossing the thread boundary // The script settled while our cancel was crossing the thread boundary
// — the seam-visible result had NOT settled when cancellation was // — the seam-visible result had NOT settled when cancellation was

View File

@@ -366,15 +366,68 @@ describe('dsh-workflow-workerthread', () => {
expect((result.value as { message: string }).message).toContain('backend exploded') expect((result.value as { message: string }).message).toContain('backend exploded')
}) })
it('maps an uncloneable ready-child result to fatal AGENT_RESULT instead of wedging the bridge', async () => { it('maps a non-JSON ready-child result to fatal AGENT_RESULT instead of wedging the bridge', async () => {
const { ctx, parent } = await setup({ const { ctx, parent } = await setup({
reply: () => ({ output: [], structured: () => { /* deliberately not cloneable */ }, stopReason: 'completed' }), reply: () => ({ output: [], structured: () => { /* deliberately outside lossless JSON */ }, stopReason: 'completed' }),
}) })
const result = await run(ctx, parent, scripted(` const result = await run(ctx, parent, scripted(`
try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } } try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } }
`)) `))
expect(result.value).toMatchObject({ code: 'AGENT_RESULT' }) expect(result.value).toMatchObject({ code: 'AGENT_RESULT' })
expect((result.value as { message: string }).message).toContain('could not cross the worker boundary') expect((result.value as { message: string }).message).toContain('subagent result must be losslessly JSON-serializable')
})
it('contains a non-JSON result even if the injected subagent service violates its normalization contract', async () => {
// SubagentService normally rejects this before the workflow sees it. Stub
// the injected seam itself so the host's defensive worker-boundary guard
// remains independently covered rather than becoming dead, untested code.
const { ctx, parent } = await setup()
const invalid = {
output: [],
structured: () => { /* deliberately outside lossless JSON */ },
stopReason: 'completed',
} as unknown as SubagentResult
const start = vi.spyOn(ctx.subagents, 'start').mockReturnValue({
id: AgentId('raw-invalid-child'),
started: Promise.resolve(),
result: Promise.resolve(invalid),
cancel: () => { /* already settled */ },
dispose: () => Promise.resolve(),
})
const result = await run(ctx, parent, scripted(`
try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } }
`))
expect(start).toHaveBeenCalledOnce()
expect(result.value).toMatchObject({ code: 'AGENT_RESULT' })
expect((result.value as { message: string }).message)
.toContain('workflow child result could not cross the worker boundary')
})
it('reads each resolved child-result field once before crossing the worker boundary', async () => {
let structuredReads = 0
class DriftedStructured { readonly value = 'drifted' }
const { ctx, parent } = await setup({
reply: () => ({
output: [],
get structured() {
structuredReads += 1
return structuredReads === 1 ? { value: 'accepted' } : new DriftedStructured()
},
stopReason: 'completed',
}),
})
const result = await run(ctx, parent, scripted(`
const found = await agent('p', {
schema: { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }
})
return found.value
`))
expect(result.value).toBe('accepted')
expect(structuredReads).toBe(1)
}) })
it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => { it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => {
@@ -703,6 +756,81 @@ describe('dsh-workflow-workerthread', () => {
await handle.dispose() await handle.dispose()
}) })
it('the settle-reap explicitly cancels a readiness-pending stray before workflow/end', async () => {
const { ctx, parent, provider } = await setup({ manual: true, deferStart: true })
const childLifecycle: string[] = []
let cancellationAtWorkflowEnd: string | undefined
ctx.on('workflow/agent-start', () => { childLifecycle.push('start') })
ctx.on('workflow/agent-end', () => { childLifecycle.push('end') })
ctx.on('workflow/end', () => {
cancellationAtWorkflowEnd = provider.runs[0]?.cancelled
})
const handle = ctx.workflows.start({
...scripted(`
agent('readiness-pending stray')
return 'done'
`),
parent,
})
const result = await handle.result
expect(result.stopReason).toBe('completed')
expect(provider.runs).toHaveLength(1)
expect(provider.runs[0]!.request.signal?.aborted).toBe(true)
expect(provider.runs[0]!.request.signal?.reason).toBe('workflow settled')
expect(provider.runs[0]!.cancelled).toBe('workflow settled')
expect(cancellationAtWorkflowEnd).toBe('workflow settled')
expect(childLifecycle).toEqual([])
await handle.dispose()
expect(provider.runs[0]!.disposeCalls).toBe(1)
})
it('contains a throwing child cancel and still settles after cancelling peer strays', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
let starts = 0
const cancelled: string[] = []
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const provider: SubagentProvider = {
name: 'throwing-cancel',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false },
inheritsParentContext: false,
start: () => {
const index = starts++
return {
id: AgentId(`throwing-cancel-${index}`),
started: new Promise(() => { /* readiness stays pending */ }),
result: new Promise(() => { /* cancellation callback owns settlement */ }),
cancel: (reason?: string) => {
if (index === 0) throw new Error('cancel callback broke')
cancelled.push(`${index}:${reason ?? 'cancelled'}`)
},
dispose: () => Promise.resolve(),
}
},
}
ctx.subagents.registerProvider(provider)
await ctx.plugin(WorkerWorkflowEngine, { provider: 'throwing-cancel', maxConcurrentAgents: 2 })
const handle = ctx.workflows.start({
...scripted(`
agent('first stray')
agent('second stray')
return 'done'
`),
parent: fakeParent(),
})
const result = await handle.result
expect(result.stopReason).toBe('completed')
expect(starts).toBe(2)
expect(cancelled).toContain('1:workflow settled')
expect(warnings.some(message => message.includes('cancel callback broke'))).toBe(true)
await handle.dispose()
})
it("cancel() drives each child's explicit cancel() host-side: a wedged worker cannot delay it", async () => { it("cancel() drives each child's explicit cancel() host-side: a wedged worker cannot delay it", async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)

View File

@@ -26,6 +26,9 @@
{ {
"path": "../../llm/llm" "path": "../../llm/llm"
}, },
{
"path": "../../core/session"
},
{ {
"path": "../../subagent/subagent" "path": "../../subagent/subagent"
}, },

80
pnpm-lock.yaml generated
View File

@@ -75,34 +75,6 @@ importers:
specifier: ^4.1.8 specifier: ^4.1.8
version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
packages/ui/user-approval:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../../core/scope
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/bash/bash: packages/bash/bash:
devDependencies: devDependencies:
'@deepseek-ai/dsh-brand': '@deepseek-ai/dsh-brand':
@@ -167,9 +139,6 @@ importers:
'@deepseek-ai/dsh-agent-loop': '@deepseek-ai/dsh-agent-loop':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/agent-loop version: link:../../core/agent-loop
'@deepseek-ai/dsh-user-approval':
specifier: workspace:^
version: link:../../ui/user-approval
'@deepseek-ai/dsh-bash': '@deepseek-ai/dsh-bash':
specifier: workspace:^ specifier: workspace:^
version: link:../bash version: link:../bash
@@ -197,6 +166,9 @@ importers:
'@deepseek-ai/dsh-tools': '@deepseek-ai/dsh-tools':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/tools version: link:../../core/tools
'@deepseek-ai/dsh-user-approval':
specifier: workspace:^
version: link:../../ui/user-approval
cordis: cordis:
specifier: ^4.0.0-rc.6 specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -436,6 +408,9 @@ importers:
'@deepseek-ai/dsh-scope': '@deepseek-ai/dsh-scope':
specifier: workspace:^ specifier: workspace:^
version: link:../scope version: link:../scope
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../session
cordis: cordis:
specifier: ^4.0.0-rc.6 specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -449,9 +424,6 @@ importers:
'@deepseek-ai/dsh-agent': '@deepseek-ai/dsh-agent':
specifier: workspace:^ specifier: workspace:^
version: link:../agent version: link:../agent
'@deepseek-ai/dsh-user-approval':
specifier: workspace:^
version: link:../../ui/user-approval
'@deepseek-ai/dsh-code-runtime': '@deepseek-ai/dsh-code-runtime':
specifier: workspace:^ specifier: workspace:^
version: link:../../code-runtime/code-runtime version: link:../../code-runtime/code-runtime
@@ -467,6 +439,9 @@ importers:
'@deepseek-ai/dsh-system-prompt': '@deepseek-ai/dsh-system-prompt':
specifier: workspace:^ specifier: workspace:^
version: link:../system-prompt version: link:../system-prompt
'@deepseek-ai/dsh-user-approval':
specifier: workspace:^
version: link:../../ui/user-approval
cordis: cordis:
specifier: ^4.0.0-rc.6 specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -849,6 +824,9 @@ importers:
'@deepseek-ai/dsh-scope': '@deepseek-ai/dsh-scope':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/scope version: link:../../core/scope
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-tools': '@deepseek-ai/dsh-tools':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/tools version: link:../../core/tools
@@ -1177,9 +1155,6 @@ importers:
'@deepseek-ai/dsh-agent-loop': '@deepseek-ai/dsh-agent-loop':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/agent-loop version: link:../../core/agent-loop
'@deepseek-ai/dsh-user-approval':
specifier: workspace:^
version: link:../user-approval
'@deepseek-ai/dsh-bash': '@deepseek-ai/dsh-bash':
specifier: workspace:^ specifier: workspace:^
version: link:../../bash/bash version: link:../../bash/bash
@@ -1228,6 +1203,9 @@ importers:
'@deepseek-ai/dsh-tools': '@deepseek-ai/dsh-tools':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/tools version: link:../../core/tools
'@deepseek-ai/dsh-user-approval':
specifier: workspace:^
version: link:../user-approval
'@deepseek-ai/dsh-user-interaction': '@deepseek-ai/dsh-user-interaction':
specifier: workspace:^ specifier: workspace:^
version: link:../user-interaction version: link:../user-interaction
@@ -1355,6 +1333,34 @@ importers:
specifier: ^4.0.0-rc.6 specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/ui/user-approval:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../../core/scope
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/ui/user-interaction: packages/ui/user-interaction:
devDependencies: devDependencies:
'@deepseek-ai/dsh-agent': '@deepseek-ai/dsh-agent':