From 36b837002718c23cd87d277e438c0fc74336bbc4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:51:55 +0800 Subject: [PATCH] fix(scope): close remaining ownership boundaries --- docs/config-catalog.md | 23 +- docs/cookbook/adding-a-tool.md | 6 +- docs/cordis-catalog/events.md | 16 +- docs/cordis-catalog/services.md | 12 +- docs/core-data-structures/skills.md | 10 +- docs/core-data-structures/tools.md | 4 +- docs/event-producer-consumer.md | 16 +- docs/module-graph.md | 43 +- docs/persistence-catalog.md | 30 +- docs/rfc/INDEX.md | 2 +- ...06-11-dev-invariants-over-deep-readonly.md | 53 +- .../2026-07-08-agent-scope-contexts.md | 76 ++- .../2026-06-21-subagent-capability-seam.md | 2 +- .../feature/2026-06-30-interception-seams.md | 6 +- .../2026-06-11-immutable-public-surfaces.md | 9 +- .../compact-basic/tests/compact-basic.spec.ts | 2 +- .../tests/compact-loop-repro.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/src/index.ts | 28 +- packages/core/agent-loop/tests/resume.spec.ts | 66 ++- .../agent-loop/tests/review-fixes.spec.ts | 12 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 32 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/index.ts | 13 +- packages/core/scope/README.md | 2 +- packages/core/scope/src/index.ts | 10 +- packages/core/scope/tests/scope.spec.ts | 10 + packages/core/session/README.md | 19 +- packages/core/session/src/index.ts | 324 ++++++++--- packages/core/session/src/json.ts | 133 ++++- packages/core/session/src/types.ts | 6 +- packages/core/session/tests/fork.spec.ts | 7 +- packages/core/session/tests/json.spec.ts | 152 +++++ packages/core/session/tests/session.spec.ts | 521 +++++++++++++++++- packages/core/system-prompt/README.md | 6 +- packages/core/system-prompt/package.json | 2 + packages/core/system-prompt/src/index.ts | 55 +- .../system-prompt/tests/system-prompt.spec.ts | 24 + .../system-prompt/tests/tool-order.spec.ts | 94 ++++ packages/core/system-prompt/tsconfig.json | 3 + packages/core/tools/README.md | 8 +- packages/core/tools/src/index.ts | 241 +++++--- packages/core/tools/src/schema.ts | 43 +- packages/core/tools/tests/scoped.spec.ts | 140 ++++- packages/core/tools/tests/tools.spec.ts | 237 +++++++- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence/README.md | 2 +- .../session-persistence/src/coordinator.ts | 47 +- .../session-persistence/src/index.ts | 28 +- .../session-persistence/tests/contract.ts | 2 +- .../tests/coordinator-contract.ts | 7 +- .../tests/persistence.spec.ts | 15 +- packages/skill/skill/README.md | 14 +- packages/skill/skill/src/index.ts | 245 +++++++- packages/skill/skill/tests/skill.spec.ts | 502 +++++++++++++++++ .../subagent-fork/tests/subagent-fork.spec.ts | 7 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 81 +-- .../subagent-inprocess/src/structured.ts | 4 +- .../tests/subagent-inprocess.spec.ts | 96 +++- packages/subagent/subagent/README.md | 8 +- packages/subagent/subagent/package.json | 2 + packages/subagent/subagent/src/index.ts | 210 +++++-- .../subagent/subagent/tests/service.spec.ts | 446 ++++++++++++++- packages/subagent/subagent/tsconfig.json | 3 + packages/support/README.md | 2 +- packages/support/invariants/README.md | 21 +- packages/support/invariants/package.json | 2 +- packages/support/invariants/src/index.ts | 73 +-- .../invariants/tests/invariants.spec.ts | 154 +++--- .../subagent-mock/tests/subagent-mock.spec.ts | 3 +- .../workflow/workflow-workerthread/README.md | 4 +- .../workflow-workerthread/package.json | 1 + .../workflow-workerthread/src/host.ts | 64 ++- .../tests/workflow-workerthread.spec.ts | 134 ++++- .../workflow-workerthread/tsconfig.json | 3 + pnpm-lock.yaml | 80 +-- 79 files changed, 3957 insertions(+), 817 deletions(-) create mode 100644 packages/core/session/tests/json.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18ef659ee0..d7b7be2d88 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -343,24 +343,6 @@ export interface Config { 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` 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` @@ -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` @@ -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-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-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)) diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 5eede7ff5d..3832f44dd0 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -34,9 +34,9 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w ## 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. -- **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. -- **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. -- **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). +- **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 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 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. - **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: ''}})` 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). diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 0c5399b41b..f5a0533842 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -285,7 +285,7 @@ A skill provider became resolvable in the `ctx.skills` registry. Consumers can o '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 @@ -295,7 +295,7 @@ A skill provider left the registry because its plugin fiber was disposed. '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/*` @@ -307,7 +307,7 @@ A started subagent run settled — emitted when SubagentRun.result resolves (any 'subagent/end'(this: Scoped, 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 @@ -317,7 +317,7 @@ A provider became resolvable in the SubagentService registry. Consumers that der '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 @@ -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 ``` -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 @@ -337,7 +337,7 @@ A subagent run started — emitted only after SubagentRun.started fulfills, when 'subagent/start'(this: Scoped, 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/*` @@ -349,7 +349,7 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -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 @@ -359,7 +359,7 @@ A section, tool provider, variable provider, or protection was registered or unr '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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ddf2a860e4..903c368b7c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -40,7 +40,7 @@ list(): Agent[] 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` @@ -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. - **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). ```ts cordis-catalog @@ -220,7 +220,7 @@ list(): 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` @@ -233,7 +233,7 @@ async list(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -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` @@ -246,7 +246,7 @@ list(): string[] 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` @@ -260,7 +260,7 @@ protect(protection: PromptProtection): () => Promise | void async assemble(context: AssembleContext = {}): Promise ``` -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` diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index b356aba720..0c5bfcae13 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -6,7 +6,7 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind ## 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 interface SkillProvider { @@ -28,7 +28,7 @@ The shipped local provider scans roots in rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/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 @@ -92,12 +92,12 @@ type SkillRegistration = Omit & { ## 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 interface SkillLookupOptions { - cwd?: string | undefined - signal?: AbortSignal | undefined + readonly cwd?: string | undefined + readonly signal?: AbortSignal | undefined } ``` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index ad741a7522..5c11f1a07c 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -81,7 +81,7 @@ type InferArgs = Simplify< `defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs`, 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 @@ -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. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1ed2405671..5a0c547e70 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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/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) | -| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:130`](../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`) | - | -| `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/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-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/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) | -| `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/change` | `emit` | [`packages/core/system-prompt/src/index.ts:55`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`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:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | +| `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: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: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: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: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: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/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) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 372c401954..8d0993b59a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -120,17 +120,13 @@ flowchart TD pkg_session --> pkg_brand pkg_session --> pkg_llm pkg_session --> pkg_scope - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope pkg_fs --> pkg_brand pkg_fs --> pkg_llm pkg_web --> pkg_llm pkg_sandbox --> pkg_llm - pkg_agent --> pkg_brand - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_system_prompt --> pkg_session pkg_bash --> pkg_brand pkg_bash --> pkg_sandbox pkg_bash --> pkg_session @@ -150,18 +146,26 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm 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_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_session pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_session 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_brand pkg_user_approval --> pkg_llm @@ -180,9 +184,6 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt 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_llm pkg_agent_loop --> pkg_scope @@ -209,6 +210,7 @@ flowchart TD pkg_subagent --> pkg_agent pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session pkg_subagent --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -289,6 +291,7 @@ flowchart TD pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_brand pkg_workflow_workerthread --> pkg_llm + pkg_workflow_workerthread --> pkg_session pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow @@ -330,11 +333,10 @@ flowchart TD | [`llm-deepseek`](../packages/llm/llm-deepseek) | `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) | -| [`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) | | [`web`](../packages/web/web) | `web` | [`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) | | [`fs-local`](../packages/fs/fs-local) | `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) | | [`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) | +| [`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) | -| [`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) | | [`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) | +| [`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-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) | | [`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) | | [`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-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) | | [`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) | @@ -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) | | [`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) | -| [`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-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) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4bbe894338..cb0e8ce0dc 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity. 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 @@ -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) -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/*` @@ -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) -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/*` @@ -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) -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/*` @@ -177,7 +177,7 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque '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 @@ -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[] } ``` -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/*` @@ -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) -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/*` @@ -213,7 +213,7 @@ Closes step `step` of turn `turn`. '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 @@ -223,7 +223,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it '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/*` @@ -239,7 +239,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess 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/*` @@ -253,7 +253,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st 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 @@ -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) -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/*` @@ -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) -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 @@ -303,7 +303,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch 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/*` @@ -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) -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) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 654b68ffb5..454997ef1f 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -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 | | [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 | | [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 | diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index a3240153d0..aac3991f46 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -1,29 +1,58 @@ -# RFC: Dev-mode invariants over compile-time deep-readonly +# RFC: Source-owned session immutability and dev-mode invariants Status: implemented ## 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` 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` would spread through every log and message consumer even though some downstream request-processing APIs intentionally work with mutable values. ## Decision -Reject the pervasive `DeepReadonly` 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. -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`). +### Session owns immutable history -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 -**The pervasive `DeepReadonly` 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 -- 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. -- The invariants plugin doubles as executable documentation of the event taxonomy — the assertions are the contract. -- `Session.events` keeps its `readonly SessionEvent[]` type; no consumer churn. -- 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. +- Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it. +- `session.events` exposes stable immutable snapshots instead of the private growing array. +- Request-side mutation cannot reach stored history through derived messages. +- 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. diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 9091965a59..981603cb19 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -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. -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 +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): - require definition.parameters is lossless JSON - parameters = clone(definition.parameters) - require parameters is still lossless JSON + accepted = read each top-level definition field exactly once + parameters = snapshotLosslessJson(accepted.parameters) stored = deepFreeze({ - copied name, description, timeout, + accepted name, description, timeout, parameters, - execute: bind definition.execute to definition, + execute: bind accepted.execute to definition, 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. -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. @@ -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. -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. @@ -356,9 +363,9 @@ Cooperative waterfalls remain the general extension mechanism, but an invariant ### 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. @@ -392,7 +399,7 @@ Code Mode uses global protection for the `tools:sdk` section and reserved `run_c ### 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. @@ -404,19 +411,18 @@ The input-to-execution conversion is intentionally one-way: ```text prepareExecution(input): - require input.parent is absent or a registry-minted token - require input.arguments is lossless JSON - detachedArguments = clone(input.arguments) - require detachedArguments is still lossless JSON + accepted = read callId, name, arguments, agent, parent, signal exactly once + require accepted.parent is absent or a registry-minted token + detachedArguments = snapshotLosslessJson(accepted.arguments) execution = { token: new frozen property-free object, - callId: input.callId, - name: input.name, + callId: accepted.callId, + name: accepted.name, arguments: deepFreeze(detachedArguments), - agent: input.agent, - parent: input.parent, - signal: input.signal + agent: accepted.agent, + parent: accepted.parent, + signal: accepted.signal } 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 -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`. @@ -468,12 +474,12 @@ execute(input): result = requireValidExecutionResult(result) result = await tools/post-execute(execution, result) - result = requireLosslessJson(result) + result = snapshotLosslessJson(result) catch pipelineFailure: result = errorResult(pipelineFailure) freeze(execution) - frozenResult = deepFreeze(clone(result)) + frozenResult = deepFreeze(snapshotLosslessJson(result)) await every tools/result observer independently, containing each failure 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. -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 returned run separates acceptance from publication with `started: Promise`. 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`, 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 startInProcessRun(providerContext, acceptedRequest): @@ -540,7 +546,7 @@ startInProcessRun(providerContext, acceptedRequest): creation = runOwner.ctx.agents.create({ fresh ids and lineage, - cloned options and optional seed, + detached options and optional seed, 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 SubagentService.start(...): - attach result settlement handlers immediately - await returnedRun.started + providerRun = provider.start(detached request) + 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 + return serviceRun Workflow worker bridge after receiving returnedRun: 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 else: 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. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 474abd2c67..f26aa501ef 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -46,7 +46,7 @@ A provider exposes `start(request) → SubagentRun`. The run carries `started` ( ### 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 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ba372b98b2..52d7e2b425 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -20,13 +20,13 @@ The canonical surface separates transformable policy, around-dispatch control, a ### 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. - **`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/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. @@ -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 -`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 diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md index 6c404a05a9..3eedd92a2d 100644 --- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md @@ -1,25 +1,24 @@ # RFC: Deep-readonly public surfaces -Status: rejected — the pervasive `DeepReadonly` 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` 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 -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 -> **Implemented differently — see the Status line and [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).** The `DeepReadonly` 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` 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: - `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session (`events`, `session/event` listeners); `append()` keeps taking plain mutable input. A `DeepReadonly` 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). - `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 -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 diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index e3d80cd567..e47ca434e3 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1700,7 +1700,7 @@ describe('BasicCompactService under the real invariants plugin', () => { async function setup(): Promise<{ ctx: Context; session: Session; svc: BasicCompactService }> { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(Invariants, {}) + await ctx.plugin(Invariants) await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) await ctx.plugin(BasicCompactService, cfg({ auto: false })) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index f2ca3ccdd0..1efb417b48 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -76,7 +76,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(Invariants, {}) + await ctx.plugin(Invariants) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7f7882f2b7..aea7927571 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -813,7 +813,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 217ba0a643..ac1a0e079f 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,13 +8,13 @@ This is the only package in the harness that contains concrete loop logic. Every ### 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-` 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): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise` — 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` — 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` — 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. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index bd5b7e3188..1e5c122fc5 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -178,20 +178,21 @@ export class AgentLoop extends Service implements AgentFactory { */ async createAgent(options: CreateAgentOptions): Promise { // Snapshot every caller-owned field before the first async setup boundary. - // The callback itself is an identity capability; all data fields are - // detached so caller mutation cannot drift a reserved/published identity or - // the options the accepted agent observes. + // The callback itself is an identity capability. Agent options detach here; + // seed and metadata stay raw only until sessions.prepare() synchronously + // reads, validates, and detaches them, so structuredClone cannot erase an + // exotic prototype before the session boundary sees it. const agentId = options.agentId const sessionId = options.sessionId const setup = options.setup const agentOptions = structuredClone(options.agentOptions ?? {}) - const seed = options.seed === undefined ? undefined : structuredClone(options.seed) - const meta = structuredClone(options.meta ?? {}) + const seed = options.seed + const meta = options.meta const release = this.reserve(agentId, sessionId) try { const session = this.ctx.sessions.prepare(sessionId, { ...seed !== undefined ? { seed } : {}, - meta, + ...meta !== undefined ? { meta } : {}, }) // A seeded (forked) create is still a fresh start, NOT a resume. 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`) }), ]) + // 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 // service's reservation, so the public enter primitives re-check exact // liveness at publication. const session = this.ctx.sessions.prepare(sessionId, { seed: events, meta: { - createdAt: meta.createdAt, - ...meta.cwd !== undefined ? { cwd: meta.cwd } : {}, - ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, - ...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {}, + createdAt, + ...cwd !== undefined ? { cwd } : {}, + ...parentSession !== undefined ? { parentSession } : {}, + ...seedLength !== undefined ? { seedLength } : {}, }, }) // Calling startOwned synchronously installs the complete lifecycle diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 2a4ba7e904..8ed557faaf 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' 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 ToolRegistry from '@deepseek-ai/dsh-tools' 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() }) + 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 () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) @@ -411,6 +427,54 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { 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 () => { // 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) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index e527099a88..3cfa743b65 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -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(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) return ctx } @@ -1014,7 +1014,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) // 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(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) 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(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) 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(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) 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(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2bf58be832..6d0bb3107b 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' 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 ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' @@ -307,6 +307,36 @@ describe('agent scope lifecycle', () => { 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 () => { const ctx = await harness() let boom = true diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 71e2be279d..23fa073ea1 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -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. - `ctx.agents.setFactory(factory: AgentFactory): () => Promise | 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` — 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` — 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` — 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 }`. 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. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index c73de89e6d..959ca4784c 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -48,7 +48,10 @@ export interface CreateAgentOptions { * `cwd`/`parentSession`/`seedLength` fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `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 } /** @@ -57,9 +60,11 @@ export interface CreateAgentOptions { * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the * 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 - * from seq 0 and balanced (no open turn/step, no dangling tool-call), or the - * session constructor (and the dev-mode invariants replay) reject it. Absent - * for a fresh (spawn) child. + * from seq 0, carry only lossless-JSON data, and be balanced (no open + * turn/step, no dangling tool-call), or the session constructor (and the + * 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[] /** Per-agent options (model, …). */ diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index e57f475816..f779dab57e 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -12,7 +12,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co - `scopeTarget(base: T, key?: ScopeKey): Scoped` 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` 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. -- `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 diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 7a7032b0b6..276b406dcd 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -320,6 +320,8 @@ export interface ScopeHost { * 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 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 services - the service names scopes minted through this host reach * (the host plugin's `inject` list). @@ -328,16 +330,20 @@ export interface ScopeHost { * Cordis dead end. */ export async function scopeHost(ctx: Context, services: string[]): Promise { + // 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 // A named function statement (not Object.assign({name}) — Function.name is // read-only) so diagnostics read `scopeHost`. 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 if (hostCtx === undefined) { // Dependency-pending: cordis resolves the await without running the // 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() /* v8 ignore next -- the '(unknown)' fallback is defensive: a pending * fiber with zero absent services cannot occur (an all-present inject diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 44b9cf4442..e572584fb1 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -367,6 +367,16 @@ describe('scopeHost', () => { .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 () => { const ctx = new Context() await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available') diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 094c3073df..36052d9e7a 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -8,7 +8,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### 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` 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.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: -- `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.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()`. -- `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.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.events`, `session.seq`, `session.id` -- `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.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.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 @@ -65,12 +70,12 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### 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 - 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. ### What is NOT here (TODO) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index d3f4ca4890..51500e9e1b 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -14,12 +14,12 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } 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 { foldRequestHeader } from './request-header.ts' export * from './types.ts' -export { isJsonValue } from './json.ts' +export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.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 { + 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 { + 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 + 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, 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. * @@ -126,10 +280,10 @@ export class Session { } /** - * Immutable creation metadata (format version, cwd, lineage, seed boundary). - * Supplied by the store via `ctx.sessions.create()`. When a `Session` is - * constructed bare (tests, ad-hoc replay), a minimal header is synthesized - * (stamped with the current {@link SESSION_FORMAT_VERSION}) so + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a * storage concern, not replayable conversation state. */ @@ -144,12 +298,27 @@ export class Session { // `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 // divergence between the live log and disk. - seed.forEach((event, index) => { - if (event.seq !== index) { - throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`) + this.log = Array.from(seed, (source, index) => { + // Spreading would erase a class instance's prototype. Reject an exotic + // 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)) { - throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`) + assertSessionEventEnvelope(snapshot, index) + 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 // 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 // SessionEvent[] (replay/fork/load), bypassing that, so re-check at // runtime here rather than silently resuming with empty history. - if (isSurfaceEligibleType(event.type) - && (event as SessionEvent).surfaceOp === undefined) { - throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`) + const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown } + try { + 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 - * a snapshot): later appends are visible through the same array. + * An immutable snapshot of the append-only event log. The snapshot is reused + * 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[] { - 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). */ @@ -205,23 +374,27 @@ export class Session { * @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 * value, never the caller's still-mutable input. - * @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 — a bad event never enters the log, - * keeping `session.events` always equal to what a backend can persist. The - * throw surfaces at the buggy caller's append site, not asynchronously in a - * backend flush. + * @throws if `type` is not a string, or if `data` or surface metadata is not + * losslessly JSON-serializable + * (BigInt, function, symbol, undefined, negative zero, non-finite number, + * circular reference, sparse array, or an exotic object such as + * Map/Set/Date/class instance). One recursive pass reads, validates, and + * copies each nested value once, so a stateful getter cannot supply one value + * 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( type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] ): SessionEvent { - if (!isJsonValue(data)) { - throw new Error(`session event "${type}" carries non-JSON-serializable data`) + if (typeof type !== 'string') { + throw new TypeError('session event type must be a string') } 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 // sole source of derived history, so a marker-less message event would be // 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 // rest collapses to optional and the compiler stops enforcing it. Re-check // at runtime so that loophole can't silently drop history. - if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) { - throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`) + const surfaceMetadata = { + ...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {}, + ...surfaceOp !== undefined ? { surfaceOp } : {}, } - // Snapshot `data` into the log, NOT the caller's reference: the validation - // above proves it is JSON-serializable AT THIS MOMENT, but the caller still - // owns the object and could mutate it afterwards (before a persistence - // flush, or permanently in the in-memory history) — making `session.events` - // diverge from the value that passed validation, or reintroducing a - // 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. + // The caller still owns the data and metadata objects and could mutate them + // after append. Materialize each accepted value exactly once while checking + // its JSON vocabulary, so the log cannot drift and a stateful getter cannot + // show one value to validation and another to a prototype-erasing clone. The + // returned event carries these SAME snapshots. // - // Surface metadata is snapshot separately: sourceEventSeqs (number[] — - // primitives, so array spread is a complete copy) and surfaceOp (a string - // primitive, or cloned if it's a replace object). + // Surface metadata accessors are read once into one plain record; the + // recursive snapshot then reads each nested value once as it copies it. // Build the event shape with conditional surface fields via spreading. // The result is cast through `unknown` because the conditional spreads // produce an intersection type that the assignability checker can't // narrow to a specific discriminated-union member when T is generic. - // This is a safe internal boundary: data was validated above, and - // surface metadata was snapshot from primitive/clone-safe values. + // This is a safe internal boundary: data and surface metadata are + // 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 = { type, seq: this.log.length, time: Date.now(), - data: structuredClone(data), - ...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {}, - ...surfaceOpts?.surfaceOp !== undefined ? { - surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp), - } : {}, + data: dataSnapshot, + ...surfaceMetadataSnapshot, } as unknown as SessionEvent - this.log.push(event as unknown as SessionEvent) - this.onAppend?.(event as unknown as SessionEvent) - return event + const acceptedEvent = deepFreeze(event) + this.log.push(acceptedEvent as unknown as SessionEvent) + this.eventsSnapshot = undefined + this.onAppend?.(acceptedEvent as unknown as SessionEvent) + return acceptedEvent } /** 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-`. * @param options - seed events and/or creation metadata for the header. * @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). */ 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-`. * @param options - seed events and/or creation metadata for the header. * @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. */ prepare(id?: SessionId, options?: CreateSessionOptions): Session { const sessionId = SessionId(id ?? `session-${++this.counter}`) if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) - const cwd = options?.meta?.cwd - if (cwd !== undefined && !isAbsolute(cwd)) { - throw new Error(`session cwd must be an absolute path, got "${cwd}"`) - } + const seed = options?.seed + const meta = snapshotSessionMeta(options?.meta) + const cwd = meta.cwd + const parentSession = meta.parentSession + const seedLength = meta.seedLength const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: sessionId, - createdAt: options?.meta?.createdAt ?? Date.now(), + createdAt: meta.createdAt ?? Date.now(), ...cwd !== undefined ? { cwd } : {}, - ...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {}, - ...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {}, + ...parentSession !== undefined ? { parentSession } : {}, + ...seedLength !== undefined ? { seedLength } : {}, } - return new Session(sessionId, options?.seed, header) + return new Session(sessionId, seed, header) } /** diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 22303c6c61..2ec36087dd 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -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 * `event.data` must round-trip losslessly through JSON so any persistence * backend can store and reload it byte-identically. This invariant belongs to * the log itself — `Session.append` enforces it at the source, so a * 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 - * predicate to validate their own `append(events)` entry point (replay/fork - * paths that do not go through a live `Session`). + * never diverge from what a backend can persist. Other public boundaries use + * {@link snapshotJsonValue} when they must validate and detach in one pass; + * {@link isJsonValue} remains the non-copying structural predicate. * * @module @deepseek-ai/dsh-session/json */ /** * 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 - * such values. The static type companion to {@link isJsonValue} (which validates - * the same shape at runtime). Use it to type a payload that must survive - * session-log persistence and replay byte-identically — e.g. a tool's private - * presentation `meta`. + * number other than negative zero, a string, an array of such values, or a + * plain object whose values are such values. TypeScript cannot distinguish + * `-0` from `number`, so {@link isJsonValue} and {@link snapshotJsonValue} + * enforce that last numeric detail at runtime. Use this type for a payload that + * 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 } /** - * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, - * booleans, strings, plain arrays, and plain objects of such values. Rejects - * `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`, - * which `JSON.stringify` 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. + * Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass. + * Each array slot or own enumerable string-keyed object value is read exactly + * once, validated, and copied immediately. This is intentionally not + * `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter + * could return plain JSON to the check and an exotic class instance to the + * clone, whose prototype `structuredClone` would erase before a later check. * - * Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE - * STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and - * non-enumerable properties are NOT examined, because `JSON.stringify` likewise - * drops them — they never reach the durable form, so a non-serializable value - * hiding under a symbol/non-enumerable key cannot make the round-trip lossy. - * Getters are invoked during the check (again as `JSON.stringify` would), so the - * contract is for plain data records, not objects with side-effecting accessors. + * Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use + * the ordinary `Array.prototype` (subclass instances are not plain JSON + * containers), while null-prototype objects are accepted and normalized to + * ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite + * numbers, unsupported scalar types, and exotic object or array shells return + * `undefined`. A throwing getter is a caller failure and propagates unchanged. + * + * @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(value: T): T | undefined { + const ancestors = new Set() + + 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)[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 seen - objects on the current descent path, for circular-reference * detection; the recursion threads it — callers omit it. @@ -52,7 +134,7 @@ export function isJsonValue(value: unknown, seen: Set = new Set()): bool case 'string': return true case 'number': - return Number.isFinite(value) + return Number.isFinite(value) && !Object.is(value, -0) case 'bigint': case 'function': case 'symbol': @@ -66,6 +148,7 @@ export function isJsonValue(value: unknown, seen: Set = new Set()): bool seen.add(value) try { if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) return false // Reject sparse arrays: a hole is skipped by `every`/`forEach` but // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip // lossily. Require every index 0..length-1 to be an OWN property. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ca6779e1dc..e564929ae0 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -32,6 +32,9 @@ export const SESSION_FORMAT_VERSION = 0 /** * 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 * 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). */ 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 * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and * — when reconstructing a persisted session — the original `createdAt` to diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 25328294cf..af143ea5ee 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -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 source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source, 1, 'hello') @@ -70,8 +70,11 @@ describe('SessionStore.fork', () => { expect(child.events).toEqual(source.events) expect(child.events).not.toBe(source.events) 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(child.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) expect(child.header).toMatchObject({ id: SessionId('child'), cwd: '/workspace', diff --git a/packages/core/session/tests/json.spec.ts b/packages/core/session/tests/json.spec.ts new file mode 100644 index 0000000000..4fb06fd744 --- /dev/null +++ b/packages/core/session/tests/json.spec.ts @@ -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, { 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(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 {} + const sparse = new Array(1) + const cyclic: Record = {} + 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 + 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, { 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 {} + const sparse = new Array(1) + const cyclic: Record = {} + 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) + }) +}) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f63353af9b..f7037609e6 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,8 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' 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', () => { it('derives message history from the event log', () => { @@ -129,6 +129,16 @@ describe('Session', () => { 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)', () => { const session = new Session(SessionId('s5b')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -156,7 +166,7 @@ describe('Session', () => { 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 } }, ] 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', () => { @@ -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: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, ] 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', () => { @@ -190,6 +200,151 @@ describe('Session', () => { 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(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, { + 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)['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', () => { const seed = [ { 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. 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, + })).toThrow(error) + } + }) + it('rejects a non-absolute meta.cwd', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index d712cd4b5b..439ebebfc7 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -14,10 +14,10 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Public API - `ctx.systemPrompt.section(section: PromptSection): () => Promise | 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` 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` 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` 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` 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.assemble(context?: AssembleContext): Promise` 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.protect(protection: PromptProtection): () => Promise | 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` 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 diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 120e10ef11..69af28f3b5 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -32,6 +33,7 @@ "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 6d3ed3af58..8d3cc501a8 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -18,6 +18,7 @@ import z from 'schemastery' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' declare module 'cordis' { interface Context { @@ -598,8 +599,9 @@ export class SystemPrompt extends Service { * restored AFTER the whole waterfall, so listener registration order cannot * strip, replace, duplicate, or fabricate it. Canonical absence is restored * too: if the protected name is intentionally absent for an assembly, a - * listener-injected entry with that name is removed. The input arrays are - * snapshotted; an empty protection throws because it cannot affect output. + * listener-injected entry with that name is removed. Each input array is + * read once and snapshotted; an empty protection throws because it cannot + * affect output. * Removed with the calling fiber and emits `system-prompt/change` on * registration/unregistration. A global section protection also reserves the * name against scoped section shadows; registering protection when such a @@ -609,9 +611,11 @@ export class SystemPrompt extends Service { */ protect(protection: PromptProtection): () => Promise | void { const scope = scopeOf(this.ctx) + const sections = protection.sections + const tools = protection.tools const snapshot: PromptProtection = { - ...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {}, - ...protection.tools !== undefined ? { tools: [...new Set(protection.tools)] } : {}, + ...sections !== undefined ? { sections: [...new Set(sections)] } : {}, + ...tools !== undefined ? { tools: [...new Set(tools)] } : {}, } if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) { 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 * name restricted away for this scope is a normal absence), and every * 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 * mutate schema objects. Runs through the `system-prompt/assemble` * waterfall, giving listeners the opportunity to mutate or replace the @@ -724,12 +730,43 @@ export class SystemPrompt extends Service { const knownNames = new Set() for (const provider of providers) { const result = provider(context) - for (const tool of result.schemas) { - collected.push({ ...tool, parameters: structuredClone(tool.parameters) }) - } - for (const name of result.knownNames ?? result.schemas.map(tool => tool.name)) { - knownNames.add(name) + // One provider result snapshot: `schemas`, `knownNames`, and each schema + // field may be accessor-backed. The same captured names must drive both + // toolOrder validation and the model-visible collection. + const inputSchemas = result.schemas + 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 = { sections: [...sectionByName.values()] diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 95d46b705e..9eef467a8e 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -258,6 +258,30 @@ describe('SystemPrompt', () => { 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 () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/core/system-prompt/tests/tool-order.spec.ts b/packages/core/system-prompt/tests/tool-order.spec.ts index 16eff6e354..088c6b70ed 100644 --- a/packages/core/system-prompt/tests/tool-order.spec.ts +++ b/packages/core/system-prompt/tests/tool-order.spec.ts @@ -48,6 +48,100 @@ describe('SystemPrompt tool order', () => { 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 () => { const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] }) ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] })) diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json index 91e7bf1ba4..a66ece4854 100644 --- a/packages/core/system-prompt/tsconfig.json +++ b/packages/core/system-prompt/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../core/session" } ] } diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 2a678408e0..0776ff39f6 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -15,14 +15,14 @@ tools: ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => Promise | 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.restrict(filter: ToolRestriction): () => Promise | 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.register(definition: ToolDefinition): () => Promise | 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` 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.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.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` 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` 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` 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 @@ -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` 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. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 71dd465dd6..b466875c75 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -23,7 +23,7 @@ import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' 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 { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' // 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 - * registry-assigned {@link token}, and a lossless-JSON-validated, deep-frozen - * clone of the parsed arguments are immutable from the first policy listener onward, while an - * around-dispatch wrapper may set, replace, or remove only `signal`. The - * registry freezes the complete object before `tools/result` observers run. + * registry-assigned {@link token}, and a deep-frozen lossless-JSON snapshot of + * the parsed arguments are immutable from the first policy listener onward, + * while an around-dispatch wrapper may set, replace, or remove only `signal`. + * The registry freezes the complete object before `tools/result` observers run. */ export interface ToolExecution extends ToolExecutionInput { /** 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 * `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 - * flows into prompt assembly automatically. Registration validates and - * clones the JSON parameters, copies scalar fields, binds each callback once - * to the caller's definition as its method receiver, and freezes the stored - * snapshot; later mutation or callback replacement on the input object does - * not rewrite the registry. Disposed with the calling fiber. Emits - * `tools/change` on register/unregister. + * flows into prompt assembly automatically. Registration materializes the JSON + * parameters in one pass, copies scalar fields, binds each callback once to the + * caller's definition as its method receiver, and freezes the stored snapshot; + * later mutation or callback replacement on the input object does not rewrite + * the registry. Every top-level field is read once into one coherent acceptance + * 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 * presentation) functions. * @returns the disposer that unregisters the tool. The exact @@ -604,31 +605,57 @@ export class ToolRegistry extends Service { */ register(definition: ToolDefinition): () => Promise | void { const scope = scopeOf(this.ctx) - // A schema crosses the same model/log boundary as execution arguments. - // Validate BEFORE cloning because structuredClone silently turns some - // forbidden values (for example class instances) into plain records, then - // validate the detached value again to contain hostile getters that change - // between inspection and snapshotting. A frozen Map is still mutable, so - // deepFreeze alone is not a sufficient registration boundary. - if (!isJsonValue(definition.parameters)) { - throw new TypeError('tool parameters must be losslessly JSON-serializable') + // One coherent acceptance snapshot: a caller may expose fields through + // accessors, so every top-level value is read exactly once before any + // validation or binding. Checked parameters and stored parameters must be + // the same reference, and a callback cannot change between lookup/bind. + const name = definition.name + const description = definition.description + const inputParameters = definition.parameters + 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 (!isJsonValue(parameters)) { - throw new TypeError('tool parameters must be stable losslessly JSON-serializable data') + if (typeof inputExecute !== 'function') throw new TypeError(`tool "${name}" execute must be a function`) + if (inputPresentCall !== undefined && typeof inputPresentCall !== 'function') { + 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 // registration cannot change dispatch, while preserving the historical // 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({ - name: definition.name, - description: definition.description, + name, + description, parameters, execute, - ...definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {}, + ...timeoutMs !== undefined ? { timeoutMs } : {}, ...presentCall !== undefined ? { presentCall } : {}, ...presentResult !== undefined ? { presentResult } : {}, }) @@ -677,8 +704,9 @@ export class ToolRegistry extends Service { * global tools they mask exist (the agent-creation `setup` window satisfies * this). A non-native mode's reserved `run_code` presentation transport is * not a filterable capability; naming it explicitly throws, while omitting - * it from an allow-list cannot remove it. The filter is SNAPSHOT at - * registration: later caller mutation of the arrays changes nothing. + * it from an allow-list cannot remove it. `allow` and `deny` are each read + * 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 * bypass restrictions (explicit grants win). Disposed with the calling * fiber (revocable independently); emits `tools/change`. @@ -692,13 +720,19 @@ export class ToolRegistry extends Service { 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') } - 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)') } // Snapshot BEFORE validation so what was checked is what is enforced. const snapshot: ToolRestriction = { - ...filter.allow !== undefined ? { allow: [...filter.allow] } : {}, - ...filter.deny !== undefined ? { deny: [...filter.deny] } : {}, + ...allow !== undefined ? { allow: [...allow] } : {}, + ...deny !== undefined ? { deny: [...deny] } : {}, } if (this.codeTransport !== undefined && [...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 * result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown * {@link HarnessError} surfaces its `{ name, code }` on the result. Before - * the final observe-only notification, the authoritative outcome must survive - * a lossless JSON round trip; an invalid outcome is normalized to an error. + * the final observe-only notification, the authoritative outcome is + * 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 * to an error before approval, guards, or the tool body. - * Caller-owned arguments must survive lossless-JSON validation before and - * after cloning; a violation normalizes to an error before policy or dispatch. - * @param exec - the single-use call input; its identity is snapshotted and - * protected before policy runs. - * @returns the final result after every waterfall; failures resolve as - * `isError` results, never rejections. + * Caller-owned arguments are validated and detached in one recursive + * lossless-JSON traversal; a violation normalizes to an error before policy + * or dispatch. + * @param exec - the single-use call input; every top-level field is read once + * and that identity snapshot is protected before policy runs (and reused by + * 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 { + // 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 try { - execution = this.prepareExecution(exec) + agent = exec.agent + parent = exec.parent + signal = exec.signal + const args = exec.arguments + const input: Readonly = Object.freeze({ + callId, + name, + arguments: args, + ...agent !== undefined ? { agent } : {}, + ...parent !== undefined ? { parent } : {}, + ...signal !== undefined ? { signal } : {}, + }) + execution = this.prepareExecution(input) } catch (error: unknown) { - // Contract-violating non-JSON or non-cloneable arguments cannot enter a - // pipeline whose logged and executed forms must agree. Still publish one - // scoped final outcome, using an immutable identity shell, so result - // observers retain their every-call guarantee without seeing the invalid - // value. + // Contract-violating arguments outside the lossless-JSON vocabulary cannot + // enter a pipeline whose logged and executed forms must agree. Still + // publish one scoped final outcome, using an immutable identity shell, so + // result observers retain their every-call guarantee without seeing the + // invalid value. execution = Object.freeze({ token: createExecutionToken(), - callId: exec.callId, - name: exec.name, + callId, + name, arguments: undefined, - ...exec.agent !== undefined ? { agent: exec.agent } : {}, - ...isExecutionToken(exec.parent) ? { parent: exec.parent } : {}, - ...exec.signal !== undefined ? { signal: exec.signal } : {}, + ...agent !== undefined ? { agent } : {}, + ...isExecutionToken(parent) ? { parent } : {}, + ...signal !== undefined ? { signal } : {}, }) const result = toolErrorResult(execution.callId, error) await this.notifyResult(execution, result) @@ -945,11 +1006,11 @@ export class ToolRegistry extends Service { } let result: ToolExecutionResult 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, - // and every one of these fields is session-bound. Reject anything that - // cannot round-trip losslessly through the durable JSON log before the - // observe-only `tools/result` commit point sees success. + // and every one of these fields is session-bound. Reject anything outside + // the lossless-JSON vocabulary before the observe-only `tools/result` + // commit point sees success. result = this.snapshotExecutionResult(execution, await this.executePipeline(execution)) } catch (error: unknown) { // 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. */ - private prepareExecution(input: ToolExecutionInput): ToolExecution { + private prepareExecution(input: Readonly): ToolExecution { if (input.parent !== undefined && !isExecutionToken(input.parent)) { 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') } - const args = structuredClone(input.arguments) - if (!isJsonValue(args)) { - throw new TypeError('tool execution arguments must be stable losslessly JSON-serializable data') - } const execution: ToolExecution = { token: createExecutionToken(), callId: input.callId, @@ -1100,10 +1158,13 @@ export class ToolRegistry extends Service { // The pipeline is over: freeze the remaining mutable signal slot so every // observer sees the SAME WeakMap-keyable execution without a mutation race. Object.freeze(exec) - // postExecute clones every accepted result/decision before rebuilding the - // outcome; all error paths construct plain data. The final result is thus - // structurally cloneable before it reaches this observe-only boundary. - const snapshot = deepFreeze(structuredClone(result)) + // Materialize once more at the observer boundary so every listener receives + // the same detached result even when an internal error path constructed it. + const detached = snapshotJsonValue(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', [ 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 // isError/error" contract. The decision is the ONLY sanctioned channel for a // listener to change the outcome (block, or accept-with-replacement); the - // call id is always the authoritative `exec.callId`. Deep cloning protects - // nested content, error, and meta data from in-place listener mutation. + // call id is always the authoritative `exec.callId`. The one-pass snapshot + // protects nested content, error, and meta from in-place listener mutation. 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, () => Promise.resolve({ kind: 'accept' }), )) + if (decision === undefined) { + throw new TypeError('tools/post-execute must return a losslessly JSON-serializable decision') + } this.assertPostDecision(decision) const additionalContext = decision.additionalContext if (decision.kind === 'block') { @@ -1200,31 +1264,36 @@ export class ToolRegistry extends Service { throw new TypeError('tools/execute must return a ToolExecutionResult object') } const result = value as Partial - 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') } - if (result.callId !== exec.callId) { - throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`) + if (callId !== exec.callId) { + throw new TypeError(`tools/execute returned callId "${String(callId)}" for authoritative call "${exec.callId}"`) } const candidate = { callId: exec.callId, - content: result.content, - isError: result.isError, - ...result.error !== undefined ? { error: result.error } : {}, - ...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {}, - ...result.meta !== undefined ? { meta: result.meta } : {}, + content, + isError, + ...error !== undefined ? { error } : {}, + ...additionalContext !== undefined ? { additionalContext } : {}, + ...meta !== undefined ? { meta } : {}, } - // Validate BEFORE cloning: structuredClone turns some forbidden exotic or - // class instances into plain objects, which would hide a lossy JSON - // boundary violation. Validate the detached clone again to contain hostile - // getters whose value changes between inspection and snapshotting. - if (!isJsonValue(candidate)) { + // One traversal both validates and detaches the accepted result. A separate + // check followed by structuredClone would reread getters and could sanitize + // a class instance into an apparently valid plain record. + const snapshot = snapshotJsonValue(candidate) + if (snapshot === undefined) { 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 } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 1a428ffd40..add9c29e61 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -20,6 +20,7 @@ */ 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 { ToolCallView, ToolResultView } from './presentation.ts' @@ -353,6 +354,12 @@ export interface DefineToolOptions { * Raw JSON-Schema tool definitions (from MCP servers) are still accepted * by `ToolRegistry.register()` directly — `defineTool` is sugar for * 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, * execute body, and optional presenters. * @returns a registry-ready {@link ToolDefinition}: its `execute` validates the @@ -362,6 +369,13 @@ export interface DefineToolOptions { * args). */ export function defineTool(options: DefineToolOptions): 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. // eslint-disable-next-line @typescript-eslint/unbound-method const userExecute = options.execute @@ -369,20 +383,31 @@ export function defineTool(options: DefineToolOptions): const userPresentCall = options.presentCall // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentResult = options.presentResult - if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { - throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) + if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { + 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 = { - name: options.name, - description: options.description, - parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, - ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + name, + description, + parameters: wireParameters as unknown as Record, + ...(timeoutMs !== undefined ? { timeoutMs } : {}), async execute(args: unknown, exec: ToolExecution): Promise { // Validate the model-generated args before the typed body runs. On // mismatch we throw ToolArgsError; the registry turns it into an // isError result so the model can self-correct. After this guard, the // cast to InferArgs reflects the validated shape. - const violations = validateArgs(options.parameters, args) + const violations = validateArgs(parameterSpec, args) if (violations.length > 0) throw new ToolArgsError(violations) return userExecute(args as InferArgs, exec) }, @@ -393,13 +418,13 @@ export function defineTool(options: DefineToolOptions): // than the hard `ToolArgsError` the execute path raises. if (userPresentCall) { 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) } } if (userPresentResult) { 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, result) } } diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index cac4a7e29b..ea4ef57ddd 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' 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 { CallId } 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']) }) + 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 () => { const ctx = await mount() const { scope } = await mintAgentScope(ctx, 'a') @@ -372,6 +391,116 @@ describe('scoped execution dispatch', () => { 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 | 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([ ['Map', new Map([['mutable', true]])], ['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 }) }) - 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() ctx.tools.register(tool('t')) let reads = 0 @@ -421,12 +550,11 @@ describe('scoped execution dispatch', () => { callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue, }) + expect(reads).toBe(1) expect(result).toEqual({ callId: CallId('unstable-arguments'), - content: [{ - type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data', - }], - isError: true, + content: [{ type: 'text', text: 'ran:t' }], + isError: false, }) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index deb53b7dc7..5bff94c960 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -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 ToolRegistry, { defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, - type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, - type ToolExecution, type ToolExecutionResult, type ToolGuard, + type DefineToolOptions, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, + type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolGuard, } from '@deepseek-ai/dsh-tools' async function setup() { @@ -135,7 +135,7 @@ describe('ToolRegistry', () => { 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() ctx.tools.register(echoTool) let reads = 0 @@ -153,15 +153,59 @@ describe('ToolRegistry', () => { callId: CallId('unstable-result'), name: 'echo', arguments: {}, }) + expect(reads).toBe(1) expect(result).toEqual({ callId: CallId('unstable-result'), - content: [{ - type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult', - }], - isError: true, + content: [{ type: 'text', text: 'safe' }], + isError: false, }) }) + 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 () => { const ctx = await setup() ctx.tools.register({ @@ -729,6 +773,29 @@ describe('ToolRegistry', () => { 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 + } + 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([ { name: 'non-object result', @@ -780,6 +847,11 @@ describe('ToolRegistry', () => { replacement: { kind: 'defer' }, 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 }) => { const ctx = await setup() ctx.tools.register(echoTool) @@ -886,7 +958,7 @@ describe('ToolRegistry', () => { 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() let reads = 0 const parameters = Object.defineProperty({}, 'properties', { @@ -898,8 +970,58 @@ describe('ToolRegistry', () => { ...echoTool, name: 'unstable-parameters', parameters, - })).toThrow('tool parameters must be stable losslessly JSON-serializable data') - expect(ctx.tools.get('unstable-parameters')).toBeUndefined() + })).not.toThrow() + 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 })) + .toThrow('presentCall must be a function') + expect(() => ctx.tools.register({ ...echoTool, name: 'bad-present-result', presentResult: 1 as unknown as NonNullable })) + .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 () => { @@ -1094,6 +1216,101 @@ describe('defineTool / schema DSL', () => { 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 + 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) => Promise.resolve([{ type: 'text' as const, text: String(args['value']) }]) + }, + }, + presentCall: { + enumerable: true, + get: () => { + reads.presentCall += 1 + return (args: Record) => ({ card: 'generic' as const, title: String(args['value']) }) + }, + }, + presentResult: { + enumerable: true, + get: () => { + reads.presentResult += 1 + return (args: Record) => ({ 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', () => { // Compile-time check: if this compiles, InferArgs is correct. // args.a is string (required), args.b is number|undefined (optional). diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 9a76381614..990ad2fcc4 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -29,4 +29,4 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ## 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. diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index c14d3a70ea..082b60af88 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -27,4 +27,4 @@ interface Config { ## 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. diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 8bd3fed568..bf7da03757 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -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. - **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. ## The write coordinator diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 0180999842..b1fc118a21 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -25,9 +25,9 @@ */ 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 { assertSerializable, seedCoversPrefix } from './index.ts' +import { seedCoversPrefix } from './index.ts' /** * A stored session's durable prefix as read back from a backend: its @@ -186,14 +186,18 @@ export class PersistenceCoordinator { /** * 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. - * @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 { // 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 // 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. - 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)) } @@ -212,23 +216,25 @@ export class PersistenceCoordinator { this.states.set(meta.id, { meta, cursor: 0, materialized: false }) } - // `async` so the synchronous validate/clone below reject (not throw) per the - // Promise contract — callers use `await expect(...).rejects`. + // `async` so synchronous materialization failures below reject (not throw) per + // the Promise contract — callers use `await expect(...).rejects`. /** * Durably persist a batch of events. Honors the append-only and contiguous-seq * contracts; rejects non-JSON-serializable `event.data`. * @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 { - // Validate serializability BEFORE cloning so a bad event surfaces the typed - // error rather than an opaque DataCloneError from structuredClone. - assertSerializable(events) - // Deep-snapshot the batch HERE, before the op waits behind the per-session - // chain: a caller that mutates a live array (e.g. session.events) — or an - // event inside it — before the op runs would otherwise have those changes - // persisted. The clone is taken synchronously (at call time). - const batch = events.map(e => structuredClone(e)) + // Validate and deep-snapshot the complete batch HERE, in one traversal, + // before the op waits behind the per-session chain. A check followed by + // structuredClone would reread accessors and could sanitize an exotic value + // into an apparently valid record; the single-pass materializer makes the + // checked value exactly the value persisted. + const batch = snapshotJsonValue(events) + if (batch === undefined) { + 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)) } @@ -338,9 +344,10 @@ export class PersistenceCoordinator { // promise so flush/dispose can await it (onCreated is async). ctx.on('session/created', (session) => { void this.initFor(session) }) - // Snapshot + buffer every event (the live object is mutable; clone so a later - // in-place mutation cannot rewrite a buffered event). Serializability is - // guaranteed at the source (Session.append), so structuredClone is safe. + // Session emits an owned frozen event. Keep a persistence-owned copy anyway + // so the write-behind queue owns exactly the record it will flush rather than + // retaining a product-layer record by identity. Serializability is guaranteed + // at the source, so structuredClone is safe. ctx.on('session/event', (session, event) => { let buffer = this.buffers.get(session) if (!buffer) this.buffers.set(session, buffer = []) @@ -391,8 +398,8 @@ export class PersistenceCoordinator { const existing = this.inits.get(session) if (existing) return existing // Snapshot the seed SYNCHRONOUSLY — initFor runs inside the `session/created` - // emit, before any later `append` adds non-seed events. A clone freezes it - // against later mutation of the live event objects. + // emit, before any later append invalidates the public array snapshot. Events + // are already frozen; cloning gives persistence independent ownership. const seed = session.events.map(e => structuredClone(e)) const p = this.onCreated(session, seed) // Attach a no-op rejection handler so a failing init does not surface as an diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 1588ed2526..b03691d17b 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -22,7 +22,7 @@ */ 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' // 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. - * Live session appends already enforce this; persistence append paths also - * accept replay/fork batches that may bypass a live session instance. - * @param events - the batch to validate; throws naming the offending event's type and seq. + * Reject a batch that is not wholly losslessly JSON-serializable. Live session + * appends already enforce this; persistence append paths also accept replay or + * direct batches that may bypass a live session instance. Validation uses the + * 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 { - for (const event of events) { - if (!isJsonValue(event.data)) { - throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`) - } + const snapshot = snapshotJsonValue(events) + if (snapshot === undefined) { + 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 * (unloadable); {@link 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 {@link 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 + * {@link 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.** {@link append} returns only once the batch is durable * (the file backend fsyncs; a DB commits). {@link create} MAY defer the * physical write until the first {@link append} (lazy materialization). diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 9f2facb827..789aa72c91 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -245,7 +245,7 @@ export function runPersistenceContract(name: string, make: () => Promise 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 { ctx, fiber } = await freshCtx(fix) try { 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' }) - // Mutate the live event object AFTER it was buffered by session/event. - ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' + expect(() => { + ;(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/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 4e4cf67822..9c766d4b66 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -158,6 +158,17 @@ describe('SessionPersistence service registration', () => { expect(loaded.events).toHaveLength(6) 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', () => { @@ -187,10 +198,10 @@ describe('shared persistence helpers', () => { 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 = [ { type: 'user/message', seq: 0, time: 1, data: { content: 1n } }, ] as unknown as SessionEvent[] - expect(() => { assertSerializable(bad) }).toThrow(/"user\/message".*seq 0/) + expect(() => { assertSerializable(bad) }).toThrow(/batch is not losslessly JSON-serializable/) }) }) diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 33d16e1b69..b035acf767 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -9,9 +9,9 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API - `ctx.skills.registerProvider(provider): () => Promise | 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.get(name, { cwd?, signal? })` Returns the full winning skill, including disabled-for-model skills. -- `ctx.skills.register(skill): () => Promise | 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.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? })` 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` 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 @@ -21,13 +21,15 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ## 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 -`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 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 20ac717e9c..f06788cde7 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -81,9 +81,10 @@ export type SkillRegistration = Omit & { provider?: /** Caller context used for cwd-sensitive and abortable provider work. */ 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. */ - signal?: AbortSignal | undefined + readonly signal?: AbortSignal | undefined } /** 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 /** * 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. * @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. // 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. + 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({ - name: provider.name, - list: provider.list.bind(provider), - get: provider.get.bind(provider), + name, + list: inputList.bind(provider), + get: inputGet.bind(provider), }) const dispose = this.ctx.effect(function* (this: SkillService) { if (snapshot.name === RUNTIME_PROVIDER) { @@ -223,7 +233,9 @@ export class SkillService extends Service { * Register a runtime skill contribution. Runtime registrations are treated as * 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 - * 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. * @returns the exact Cordis effect disposer that removes this runtime * 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. * @returns sorted summaries, excluding skills disabled for model invocation. */ async list(options: SkillLookupOptions = {}): Promise { - return (await this.collect(options)) + const accepted = snapshotLookupOptions(options) + return (await this.collect(accepted)) .map(entry => entry.candidate) .filter(skill => skill.disableModelInvocation !== true) .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 options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. */ async get(name: string, options: SkillLookupOptions = {}): Promise { 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 - 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 { - options.signal?.throwIfAborted() + throwIfAborted(options.signal) while (true) { const providerRevision = this.providerRevision const runtimeRevision = this.runtimeRevision @@ -285,7 +312,7 @@ export class SkillService extends Service { if (cached !== undefined) return cached const result = await this.collectFresh(options) - options.signal?.throwIfAborted() + throwIfAborted(options.signal) if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue if (result.cacheable) { this.collectCache.set(key, result.entries) @@ -316,7 +343,7 @@ export class SkillService extends Service { } private async listAllCandidates(options: SkillLookupOptions): Promise { - options.signal?.throwIfAborted() + throwIfAborted(options.signal) const candidates: IndexedCandidate[] = [] let cacheable = true let runtimeOrder = 0 @@ -340,9 +367,12 @@ export class SkillService extends Service { this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`) } 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) { - validateCandidate(candidate, provider.name) - candidates.push({ candidate, provider, providerOrder: order, localOrder }) + const snapshot = snapshotCandidate(candidate, provider.name) + candidates.push({ candidate: snapshot, provider, providerOrder: order, localOrder }) 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 { + if (typeof candidate.name !== 'string') { + throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`) + } if (!SKILL_NAME.test(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) { 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`) } + if (typeof candidate.provider !== 'string') { + throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`) + } if (candidate.provider !== providerName) { 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 { - if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`) - if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`) + // Read every caller-owned top-level field once so validation and storage use + // 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 { - ...skill, - provider: skill.provider ?? RUNTIME_PROVIDER, - source: skill.source, + name, + description, + ...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 } : {}, source, 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 }) } +/** Capture one lookup identity before any provider or cache async boundary. */ +function snapshotLookupOptions(options: SkillLookupOptions): Readonly { + const cwd = options.cwd + const signal = options.signal + return Object.freeze({ + ...cwd !== undefined ? { cwd } : {}, + ...signal !== undefined ? { signal } : {}, + }) +} + function waitWithAbort(promise: Promise, signal: AbortSignal | undefined): Promise { if (signal === undefined) return promise - signal.throwIfAborted() + throwIfAborted(signal) return new Promise((resolve, reject) => { const cleanup = (): void => { signal.removeEventListener('abort', onAbort) @@ -467,12 +640,28 @@ function waitWithAbort(promise: Promise, signal: AbortSignal | undefined): }) } -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)) +/** Throw a total Error for an already-aborted lookup. */ +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 { - return String(error) + try { + return String(error) + } catch { + return '[unrenderable thrown value]' + } } export default SkillService diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index e3b491f690..b47bdb6431 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -165,6 +165,480 @@ describe('SkillService registry', () => { 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; 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((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((resolve) => { markStarted = resolve }) + const held = new Promise((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[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; 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; 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 () => { const defaultedService = new SkillService(new Context()) expect(await defaultedService.list()).toEqual([]) @@ -282,6 +756,34 @@ describe('SkillService registry', () => { 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 () => { const ctx = new Context() await ctx.plugin(SkillService) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index f9c7b04a51..66234a012c 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -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 - * real dsh-invariants plugin. The invariants plugin re-replays a seeded child - * log on `session/created` (its freeze-check), so a malformed (unbalanced) fork - * seed makes these tests THROW — that is the regression guard for the - * completed-turn-prefix boundary. + * real dsh-invariants plugin. The plugin replays a seeded child log on + * `session/created`, so a malformed (unbalanced) fork seed makes these tests + * THROW — that is the regression guard for the completed-turn-prefix boundary. */ async function setup(script: Script) { const ctx = new Context() diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index caa7b9b161..e9f7beb540 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -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`): -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; 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). diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index de3b042fe3..518ea6a8b4 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -18,9 +18,9 @@ import { randomUUID } from 'node:crypto' import type { Context, Fiber } from 'cordis' 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 { 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 { attachStructuredRuntime, @@ -125,59 +125,74 @@ export function startInProcessRun( request: SubagentStartRequest, options: InProcessRunOptions, ): SubagentRun { - // Snapshot the accepted request synchronously. The parent and signal are - // identity capabilities (kept live but never reread from the mutable request - // record); every data field is detached before asynchronous owner setup. + // Capture every top-level field once. Parent/signal are identity capabilities; + // every data value is materialized below before asynchronous owner setup. const parent = request.parent const signal = request.signal const persona = request.persona - const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter) - const seed = options.seed === undefined ? undefined : structuredClone(options.seed) + const inputToolFilter = request.toolFilter + 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 - if (request.maxDepth !== undefined && childDepth > request.maxDepth) { - throw new SubagentDepthError(childDepth, request.maxDepth) + if (inputMaxDepth !== undefined && childDepth > inputMaxDepth) { + throw new SubagentDepthError(childDepth, inputMaxDepth) } - // Assert, then snapshot, the schema subset BEFORE any child exists (the - // service has already capability-gated; this rejects a schema outside the - // enforced subset loud). Assertion comes FIRST so a hostile value fails as - // OutputSchemaError, never as structuredClone's raw DataCloneError — the - // asserted subset is plain JSON data, which always clones. The snapshot is - // load-bearing: the caller keeps its reference, so attaching the ORIGINAL - // would let a post-start() mutation drift the enforced schema away from the - // asserted one — the clone (taken synchronously with the assertion, no - // interleaving possible) pins assertion, the model-visible parameters, and - // validateStructuredValue to one isolation-immutable value. - if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) - const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema) + const requestedAgentOptions = inputAgentOptions === undefined + ? {} + : snapshotJsonValue(inputAgentOptions) + if (requestedAgentOptions === undefined) { + throw new TypeError('subagent agent options must be losslessly JSON-serializable') + } + // Materialize, then assert, the schema subset BEFORE any child exists. The + // single traversal rejects non-JSON data without rereading accessors; the + // detached value then pins assertion, model-visible parameters, and runtime + // validation to one provider-owned schema. Contract failures stay typed as + // OutputSchemaError rather than leaking a materialization detail. + 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 - // content array. Validate the same lossless-JSON contract Session.append - // enforces before any child exists, then detach it synchronously so mutation - // during async creation cannot change what is logged or sent to the model. - if (!isJsonValue(request.prompt)) { + // content array. Use the same one-pass boundary Session.append enforces before + // any child exists so later mutation cannot change what is logged or sent. + const prompt = snapshotJsonValue(inputPrompt) + if (prompt === undefined) { 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()) // The child's OWN events begin after the seed (fork seeds the parent's // completed-turn prefix; spawn seeds nothing). `readResult` scopes to this // boundary so a child that produces no message of its own never returns the // 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 // Inherit the parent's model by default (a child with no model cannot run); // an explicit `request.agentOptions.model` overrides it. The deployment // persona needs no inheritance (a context-wide section both render); a // per-child `request.persona` becomes a SCOPED section of the same name in // the setup below, shadowing the deployment's for this child alone. - const agentOptions: AgentOptions = structuredClone({ - ...parent.options.model !== undefined ? { model: parent.options.model } : {}, - ...request.agentOptions, + const parentModel = parent.options.model + const agentOptions = snapshotJsonValue({ + ...parentModel !== undefined ? { model: parentModel } : {}, + ...requestedAgentOptions, 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 // window. The factory awaits it before inserting or announcing the child, so diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index dff63486ed..d4267ae009 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -79,8 +79,8 @@ export interface StructuredAttachment { * agent-creation `setup` window with the child's scope context — every * registration rides the child's fiber and unwinds with the child. * @param childCtx - the child agent's scope context (`setup`'s argument). - * @param schema - the isolation-cloned, already-asserted schema subset to - * enforce (see `assertSupportedOutputSchema` in dsh-tools). + * @param schema - the detached, already-asserted schema subset to enforce (see + * `assertSupportedOutputSchema` in dsh-tools). * @returns the attachment handle (read `captured()` after the child settles). */ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment { diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 648ee5935f..f8e18ed572 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' 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 ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' 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 { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' +import { depthOf, type InProcessRunOptions, SubagentDepthError, startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -57,7 +57,7 @@ describe('startInProcessRun', () => { }, {})).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([]) let reads = 0 const prompt = [{ @@ -68,9 +68,91 @@ describe('startInProcessRun', () => { }, }] - expect(() => startInProcessRun(ctx, { prompt, parent }, {})) - .toThrow('subagent prompt must be stable losslessly JSON-serializable data') - expect(reads).toBe(2) + const run = startInProcessRun(ctx, { prompt, parent }, {}) + expect(reads).toBe(1) + 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, + }, + 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 () => { diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e214ba58c6..b068cfe79d 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -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. | | `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). | | `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 @@ -32,12 +32,12 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: ` ## 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) -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. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index eb0dbf8da0..cf1ebf9485 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -32,6 +33,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index e1f4fac594..de376686fd 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -34,10 +34,11 @@ import { Context, Service } from 'cordis' 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 { HarnessError } from '@deepseek-ai/dsh-llm' +import { deepFreeze, HarnessError } 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 { 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 { /** The provider that started the run. */ provider: string @@ -123,7 +124,7 @@ export interface SubagentRunInfo { 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 { /** The provider that ran it. */ provider: string @@ -186,11 +187,12 @@ export class SubagentService extends Service { // mutate or reuse the provider object before its old fiber unloads. Binding // preserves the provider method's receiver while making replacement of the // public callback field after registration inert. + const inputCapabilities = provider.capabilities const capabilities: SubagentCapabilities = Object.freeze({ - outputSchema: provider.capabilities.outputSchema, - depthLimit: provider.capabilities.depthLimit, - toolFilter: provider.capabilities.toolFilter, - persona: provider.capabilities.persona, + outputSchema: inputCapabilities.outputSchema, + depthLimit: inputCapabilities.depthLimit, + toolFilter: inputCapabilities.toolFilter, + persona: inputCapabilities.persona, }) const snapshot: SubagentProvider = Object.freeze({ name: provider.name, @@ -244,10 +246,16 @@ export class SubagentService extends Service { /** * 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` * 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 * that fails before establishing a child emits neither event. * @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). */ 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) if (!provider) { throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER') } - this.assertCapabilities(provider, request) - if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) + // Read every top-level field exactly once before capability checks or + // 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 // are live identity capabilities and stay exact; the mutable request record // and its arrays/objects are never retained, so every backend (including an // async out-of-process one) observes the request accepted at start. const accepted: SubagentStartRequest = { - prompt: structuredClone(request.prompt), + prompt, parent, - ...request.signal !== undefined ? { signal: request.signal } : {}, - ...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {}, - ...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {}, - ...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {}, - ...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {}, - ...request.persona !== undefined ? { persona: request.persona } : {}, + ...input.signal !== undefined ? { signal: input.signal } : {}, + ...agentOptions !== undefined ? { agentOptions } : {}, + ...outputSchema !== undefined ? { outputSchema } : {}, + ...input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {}, + ...toolFilter !== undefined ? { toolFilter } : {}, + ...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 // 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 // that a child started. } - void run.result.then( - (result) => { - // 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)}`) - } + void result.then( + (value) => { deliverEnd({ provider: name, - id: run.id, - stopReason: result.stopReason, - ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {}, + id, + stopReason: value.stopReason, + 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 @@ -324,10 +384,10 @@ export class SubagentService extends Service { // per-listener containment, then flush an outcome that settled unusually // early. A readiness rejection is handled here and deliberately emits no // false start/end pair; the result path above remains independently handled. - void run.started.then( + void started.then( () => { readiness = 'started' - this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) + this.emitLifecycle('subagent/start', { provider: name, id }, parent) if (pendingEnd !== undefined) { const info = pendingEnd pendingEnd = undefined @@ -342,6 +402,54 @@ export class SubagentService extends Service { 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 { + 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 * 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 // provider-removed registry notification stays unfiltered. The carrier is // 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 - ? [name, info] - : [scopeTarget(this, parent), name, info] + ? [name, acceptedInfo] + : [scopeTarget(this, parent), name, acceptedInfo] for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) { try { - callback(info) + callback(acceptedInfo) } 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 '' + } +} + export default SubagentService diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 7b9e828c37..ba4b58c2fc 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -173,6 +173,16 @@ describe('SubagentService', () => { persona: true, } 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 removed: string[] = [] ctx.on('subagent/provider-added', registered => void added.push(registered)) @@ -184,6 +194,7 @@ describe('SubagentService', () => { pluginCtx.subagents.registerProvider(provider) }, }) + expect(capabilityReads).toBe(1) const accepted = ctx.subagents.getProvider('stable') const mutable = provider as unknown as { @@ -216,7 +227,10 @@ describe('SubagentService', () => { expect(ctx.subagents.list()).toEqual(['stable']) expect(ctx.subagents.getProvider('mutated')).toBeUndefined() + const controller = new AbortController() const run = ctx.subagents.start('stable', baseRequest({ + signal: controller.signal, + agentOptions: { model: 'mock' }, outputSchema: { type: 'object', properties: { answer: { type: 'string' } } }, maxDepth: 2, toolFilter: { deny: ['bash'] }, @@ -277,6 +291,142 @@ describe('SubagentService', () => { ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 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, + }, + 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 () => { @@ -299,6 +449,165 @@ describe('SubagentService', () => { 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 { + 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 () => { const ctx = new Context() 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 // start() returns — i.e. BEFORE the caller's own `await run.result` // continuation. If the event shared the result.output reference, a mutating - // listener would change the SubagentResult the caller consumes. The service - // deep-clones output onto the event, so the listener mutates only its copy. + // listener would change the SubagentResult the caller consumes or the value + // a later observer sees. The service freezes one normalized result and the + // lifecycle payload before dispatching either public surface. const ctx = new Context() await ctx.plugin(SubagentService) ctx.subagents.registerProvider(new StubProvider( @@ -448,12 +758,22 @@ describe('SubagentService', () => { if (blocks?.[0]?.type === 'text') blocks[0].text = 'HIJACKED' blocks?.push({ type: 'text', text: 'injected' }) }) + const later = vi.fn() + ctx.on('subagent/end', later) const run = ctx.subagents.start('clone', baseRequest()) const result = await run.result 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(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 + 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 () => { @@ -483,17 +803,14 @@ describe('SubagentService', () => { 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 () => { - // The clone runs inside onFulfilled, OUTSIDE emitLifecycle's per-listener - // containment. An uncloneable output (here a content block carrying a - // function) would otherwise throw and become an unhandled rejection on the - // detached `.then`. The handler must instead log and emit the event WITHOUT - // lastAssistantMessage, still carrying the real stopReason. + it('rejects an invalid provider output and maps the contract fault to error telemetry', async () => { + // A function is outside the lossless JSON vocabulary. The service-owned + // result promise rejects instead of exposing the malformed provider value; + // its already-attached lifecycle observer maps that infrastructure fault to + // error telemetry without producing an unhandled rejection. const ctx = new Context() await ctx.plugin(SubagentService) - const warn = vi.fn(); ctx.logger.warn = warn as never - // An output value structuredClone cannot handle (a function is uncloneable). - const uncloneable = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output'] + const nonJsonOutput = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output'] ctx.subagents.registerProvider({ name: 'unclone', capabilities: NO_CAPS, @@ -501,7 +818,7 @@ describe('SubagentService', () => { start: () => ({ id: AgentId('unclone-child'), started: Promise.resolve(), - result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult), + result: Promise.resolve({ output: nonJsonOutput, stopReason: 'completed' } as SubagentResult), cancel() {}, dispose: async () => {}, }), @@ -510,13 +827,52 @@ describe('SubagentService', () => { const ended = vi.fn() ctx.on('subagent/end', ended) 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() const endInfo = ended.mock.calls[0]![0] as Record - expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved - expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed - expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone')) + expect(endInfo.stopReason).toBe('error') + expect('lastAssistantMessage' in endInfo).toBe(false) + }) + + 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 () => { @@ -566,6 +922,60 @@ describe('SubagentService', () => { 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(''))).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 () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index f93f929241..3d3c9be6a9 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, { "path": "../../llm/llm" }, diff --git a/packages/support/README.md b/packages/support/README.md index 2a08063bad..c8883a89ff 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -5,7 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than | 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) | -| `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`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 94c2682f9d..4f50ec42f3 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -1,9 +1,11 @@ # 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. +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 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 -await ctx.plugin(Invariants) // freeze on (default) -await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freeze +await ctx.plugin(Invariants) ``` -`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`. - -### 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. | +`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. ## Invariants asserted @@ -46,10 +41,10 @@ Model requests (on `llm/stream`): On any violation it throws `InvariantError` (`code: 'INVARIANT'`). -## Why runtime, not deep-readonly types +## Why runtime assertions remain useful -A `DeepReadonly` 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` 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 -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. diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 4ea36f66de..ba95eea315 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -1,6 +1,6 @@ { "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", "private": true, "type": "module", diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index ee431a7f39..153d97a45c 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,20 +1,18 @@ /** - * Dev-mode invariants: a pure-listener 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 invariants: a pure-listener plugin that asserts relationships in + * the harness event contract at runtime. * * Everything is a plugin — this is just listeners on `session/created`, - * `session/event`, and `agent/status`. It is **off in production**: enable it - * in tests and the demos, where a contract violation should be a loud failure, - * not a subtle one. It doubles as executable documentation of the event - * taxonomy: the assertions below ARE the contract. + * `session/event`, `agent/status`, and the scoped dispatch and request seams. + * It is **off in production**: enable it in tests and demos, where a contract + * violation should be a loud failure rather than a subtle one. It doubles as + * executable documentation of the event taxonomy: the assertions below are + * the contract. * - * Why runtime assertions instead of compile-time deep-readonly types? See - * the dev-invariants RFC. Briefly: a `DeepReadonly` is high type-noise across - * every log consumer and a plugin casts straight through it; a dev-mode freeze - * + assertions catch real corruption at zero production cost and zero type - * noise. The always-on half of that defense (cloning derived messages) lives - * in dsh-session; this package is the dev-mode tripwire. + * Session owns immutable log storage: it snapshots and deep-freezes every + * accepted event at the source. This plugin checks relationships that one + * event's types and immutability cannot express, including turn/step nesting, + * scoped dispatch, status transitions, and request reconstructability. * * @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. */ interface SessionTrace { /** Highest `seq` seen so far (must strictly increase). */ @@ -87,29 +75,6 @@ interface AgentSubject { 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 = 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)[key], seen) - } -} - /** Assert that a step-scoped event names the currently open turn and step. */ function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void { 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 - * disposing the plugin fiber removes all listeners and stops freezing - * (HMR-safe). On (re-)apply the trace state is rebuilt by replaying each - * existing session's log, so a hot reload mid-turn does not falsely reject the - * next event. + * disposing the plugin fiber removes all listeners (HMR-safe). On (re-)apply + * the trace state is rebuilt by replaying each existing session's log, so a + * hot reload mid-turn does not falsely reject the next event. + * + * @param ctx - Cordis context that receives the invariant listeners. */ -export function apply(ctx: Context, config: Config = {}): void { - const freeze = config.freeze ?? true +export function apply(ctx: Context): void { const traces = new WeakMap() // 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. @@ -334,13 +299,12 @@ export function apply(ctx: Context, config: Config = {}): void { 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 trace = freshTrace() traces.set(session, trace) for (const event of session.events) { checkEvent(trace, event) - if (freeze) deepFreeze(event) } return trace } @@ -362,7 +326,6 @@ export function apply(ctx: Context, config: Config = {}): void { ctx.on('session/event', (session, event) => { checkEvent(traceFor(session), event) - if (freeze) deepFreeze(event) }) ctx.on('agent/status', (agent, status) => { diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index ad3792b302..44a87abf8c 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -8,10 +8,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' import { InvariantError } from '@deepseek-ai/dsh-invariants' /** A Context with the session store and the invariants plugin registered. */ -async function setup(config?: { freeze?: boolean }) { +async function setup() { const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(Invariants, config ?? {}) + const fiber = await ctx.plugin(Invariants) return { ctx, fiber } } @@ -22,7 +22,7 @@ function mockAgent(id: string): Agent { describe('session-log invariants', () => { 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() expect(() => { 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() // Session.append enforces seq-contiguity at the source, so drive the // 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() 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' } } })) @@ -56,7 +56,7 @@ describe('session-log invariants', () => { }) 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() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() 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/) }) 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() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() 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' } })) @@ -87,7 +87,7 @@ describe('session-log invariants', () => { }) 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() // 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' })) @@ -97,7 +97,7 @@ describe('session-log invariants', () => { }) 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() // steering/message is turn-scoped: outside a turn it would land past the // 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() 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' })) @@ -121,7 +121,7 @@ describe('session-log invariants', () => { }) 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() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() expect(() => { 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() expect(() => { 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() // A seq-contiguous, serializable seed (so it passes Session's constructor // validation) that nonetheless violates turn nesting — a second turn/start // 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const a = ctx.sessions.create(SessionId('a')) const b = ctx.sessions.create(SessionId('b')) 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() expect(() => { 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) @@ -266,7 +266,7 @@ describe('HMR state rebuild', () => { const ctx = new Context() await ctx.plugin(SessionStore) // 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() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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 // 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' } })) .not.toThrow() // And a genuine violation is still caught after the rebuild. @@ -283,76 +283,49 @@ describe('HMR state rebuild', () => { }) }) -describe('dev-freeze', () => { - it('freezes appended event data so mutating a logged event throws', async () => { - const { ctx } = await setup() // freeze defaults true - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) +describe('session immutability', () => { + it('always freezes appended event data without the invariants plugin', () => { + const session = new Session(SessionId('appended')) 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.data)).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() }) - it('does not freeze when freeze:false', async () => { - 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() + it('always freezes seeded events without the invariants plugin', () => { 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: '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]?.data)).toBe(true) + expect(Object.isFrozen(session.events[1]?.data)).toBe(true) }) - it('freezes mutable descendants of a shallow-frozen event datum', async () => { - const { ctx } = await setup() - 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. + it('snapshots and freezes descendants of a shallow-frozen caller value', () => { + const session = new Session(SessionId('shallow-frozen')) 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 event = session.append('user/message', { content: [block], source: { kind: 'user' } }, { surfaceOp: 'append' }) 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[0])).toBe(true) + innerContent[0]!.text = 'caller mutation' + expect(logged.content[0]!.text).toBe('inner') 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 = { 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', () => { it('accepts legal transitions: idle→running→idle and →disposed', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const agent = mockAgent('a1') expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') @@ -363,28 +336,28 @@ describe('agent status invariants', () => { }) it('accepts running→disposed', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const agent = mockAgent('a2') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow() }) it('rejects a no-op transition', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const agent = mockAgent('a3') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }).toThrow(/no-op transition/) }) it('rejects leaving the terminal disposed state', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const agent = mockAgent('a4') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/) }) it('tracks status per agent independently', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const a = mockAgent('a5') const b = mockAgent('b5') ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') @@ -401,10 +374,10 @@ describe('HMR safety', () => { await fiber.dispose() - // After disposal: no freezing, no assertions. An event that WOULD have - // violated the open-turn rule now passes silently, and is not frozen. + // After disposal the plugin's assertions are gone, so an event that would + // violate the open-turn rule passes. Session still owns immutability. 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. const agent = mockAgent('hmr') 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') }) - it('does not leak listeners across dispose (no stale freezing)', async () => { + it('does not leak listeners across dispose', async () => { const { ctx, fiber } = await setup() await fiber.dispose() const spy = vi.fn() ctx.on('session/event', spy) const session = ctx.sessions.create() 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(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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() 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: '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 session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // Type system prevents surface metadata on non-surface events; this test - // exercises the runtime guard against casts or persisted-data bypass. - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return - expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { sourceEventSeqs: [0] })) + // Session rejects this at its own acceptance boundary. Emit a hand-built + // record to cover the listener's defensive check for alternate producers. + const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, sourceEventSeqs: [0] } + expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }) .toThrow(/cannot carry sourceEventSeqs/) }) @@ -666,8 +640,8 @@ describe('surface invariants', () => { const { ctx } = await setup() const session = ctx.sessions.create() 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 - expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { surfaceOp: 'append' })) + const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, surfaceOp: 'append' } + expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }) .toThrow(/cannot carry surfaceOp/) }) }) @@ -675,7 +649,7 @@ describe('surface invariants', () => { 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. */ async function requestSetup() { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create(SessionId('req-check')) 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' }) @@ -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 () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create(SessionId('req-bare')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) 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() await ctx.plugin(SessionStore) 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')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index ddd725da4b..8a96cc4141 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -57,7 +57,8 @@ describe('dsh-subagent-mock', () => { // the structured path is only reachable when the cap is on; with it off and // no schema requested, the result has no structured field. 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 () => { diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 86d386350a..d7cd9ed64c 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -35,9 +35,9 @@ Values LEAVING the script (hook options/schemas, the script's return) are materi ## 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. diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index f86c74c2f7..ed934cd0cc 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^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-tools": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1", diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index fff9d8496f..b1d92ac1d0 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -15,10 +15,13 @@ * 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 - * accepts them, so cancellation reaches even a pre-publication attempt. 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 + * accepts them, so cancellation reaches even a pre-publication attempt. Both + * explicit run cancellation and the shared request signal are driven when the + * workflow is cancelled OR normally settles, so a fire-and-forget child cannot + * 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 * 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), @@ -44,6 +47,7 @@ import type { WorkerOptions } from 'node:worker_threads' import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { assertNever } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { SubagentRun } from '@deepseek-ai/dsh-subagent' import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow' import { renderThrown } from './realm.ts' @@ -180,11 +184,10 @@ export class WorkerRun implements WorkflowRun { if (this.settled || this.cancelReason !== undefined) return this.cancelReason = reason ?? 'workflow cancelled' 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 // provider honoring only run.cancel() must not wait on a wedged worker's // 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(() => { // The worker may no longer speak (it is about to be terminated): pair // 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) break 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 case WorkerToHostType.ChildDispose: this.onChildDispose(message.callId) @@ -322,11 +328,19 @@ export class WorkerRun implements WorkflowRun { const forwardResult = run.result.then<() => void, () => void>( (result) => { try { - const snapshot: ChildResult = structuredClone({ - output: result.output, - ...result.structured !== undefined ? { structured: result.structured } : {}, - stopReason: result.stopReason, + // Capture every provider-owned field once, then materialize the + // worker-bound value in one lossless traversal. A stateful accessor + // cannot validate one result and send another, and an exotic value is + // rejected before any prototype-erasing clone. + const output = result.output + const structured = result.structured + const stopReason = result.stopReason + const snapshot = snapshotJsonValue({ + 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 }) } } catch (error: unknown) { 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. */ 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]) { - run.cancel(this.cancelReason ?? reason) 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 { - // The worker's settle-reap already child-cancel()s every stray; this - // abort fires the seam signal too, for providers that only honor the - // request signal (both channels, on every path). - if (this.cancelReason === undefined) this.controller.abort('workflow settled') + // The worker cancels handles it already received, but a fire-and-forget + // child may still be waiting on readiness and therefore have no worker + // handle. Drive BOTH provider-permitted channels from the host before the + // workflow becomes externally settled. + if (this.cancelReason === undefined) this.cancelChildren('workflow settled') if (this.cancelReason !== undefined && result.stopReason !== 'cancelled') { // The script settled while our cancel was crossing the thread boundary // — the seam-visible result had NOT settled when cancellation was diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 23d4de0f07..46a42e4036 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -366,15 +366,68 @@ describe('dsh-workflow-workerthread', () => { 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({ - reply: () => ({ output: [], structured: () => { /* deliberately not cloneable */ }, stopReason: 'completed' }), + reply: () => ({ output: [], structured: () => { /* deliberately outside lossless JSON */ }, stopReason: 'completed' }), }) const result = await run(ctx, parent, scripted(` 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 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 () => { @@ -703,6 +756,81 @@ describe('dsh-workflow-workerthread', () => { 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 () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/workflow/workflow-workerthread/tsconfig.json b/packages/workflow/workflow-workerthread/tsconfig.json index 385651c192..730a3e61d9 100644 --- a/packages/workflow/workflow-workerthread/tsconfig.json +++ b/packages/workflow/workflow-workerthread/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../core/session" + }, { "path": "../../subagent/subagent" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68ec4e3183..575f2bbf9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,34 +75,6 @@ importers: 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)) - 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: devDependencies: '@deepseek-ai/dsh-brand': @@ -167,9 +139,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../ui/user-approval '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -197,6 +166,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval 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) @@ -436,6 +408,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session 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) @@ -449,9 +424,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../ui/user-approval '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../../code-runtime/code-runtime @@ -467,6 +439,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval 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) @@ -849,6 +824,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -1177,9 +1155,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../user-approval '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -1228,6 +1203,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../user-interaction @@ -1355,6 +1333,34 @@ importers: 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-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: devDependencies: '@deepseek-ai/dsh-agent':