From 9d310ecc27954f412e709fe8a4dd891406ff2e1d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:19:57 +0800 Subject: [PATCH 01/30] feat(invariants): add package-owned service seam --- docs/architecture.md | 7 +- docs/capability-seams.md | 14 +- docs/config-catalog.md | 26 +- docs/cordis-catalog/services.md | 18 + docs/core-data-structures/session.md | 4 +- docs/event-producer-consumer.md | 10 +- docs/module-graph.md | 21 +- docs/rfc/INDEX.md | 1 + ...06-11-dev-invariants-over-deep-readonly.md | 10 +- .../2026-06-11-structured-error-taxonomy.md | 4 +- .../2026-06-15-turn-enclosure-invariant.md | 2 +- .../2026-06-18-session-surface.md | 3 +- .../2026-07-05-reconstructable-requests.md | 2 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- ...-package-owned-invariant-service.i18n.yaml | 6 + ...6-07-19-package-owned-invariant-service.md | 97 ++ ...7-19-package-owned-invariant-service.zh.md | 97 ++ .../2026-06-18-compaction-capability-seam.md | 4 +- .../feature/2026-07-07-session-prefix.md | 2 +- ...pt-program-backed-semantic-gates.i18n.yaml | 4 +- ...ypescript-program-backed-semantic-gates.md | 8 +- ...script-program-backed-semantic-gates.zh.md | 8 +- .../2026-06-16-typed-event-schemas.md | 4 +- .../tests/compact-loop-repro.spec.ts | 16 +- .../cordis/tool-cordis/src/api-catalog.ts | 18 + packages/core/agent-loop/README.md | 4 + packages/core/agent-loop/package.json | 6 + packages/core/agent-loop/src/invariant.ts | 73 ++ .../tests/contract-regressions.spec.ts | 30 +- .../core/agent-loop/tests/invariant.spec.ts | 103 +++ .../core/agent-loop/tests/turn-stop.spec.ts | 14 +- packages/core/agent-loop/tsconfig.json | 3 + packages/core/agent-loop/tsdown.config.ts | 25 + packages/core/agent/README.md | 2 + packages/core/agent/package.json | 7 + packages/core/agent/src/invariant.ts | 35 + packages/core/agent/tests/invariant.spec.ts | 58 ++ packages/core/agent/tsconfig.json | 3 + packages/core/agent/tsdown.config.ts | 25 + packages/core/scope/README.md | 2 + packages/core/scope/package.json | 7 + packages/core/scope/src/invariant.ts | 41 + .../core/scope/src/scoped-events.generated.ts | 50 + packages/core/scope/tests/invariant.spec.ts | 81 ++ packages/core/scope/tsconfig.json | 3 + packages/core/scope/tsdown.config.ts | 27 + packages/core/session/README.md | 4 +- packages/core/session/package.json | 7 + packages/core/session/src/invariant.ts | 230 +++++ packages/core/session/tests/invariant.spec.ts | 268 ++++++ packages/core/session/tsconfig.json | 3 + packages/core/session/tsdown.config.ts | 25 + packages/examples/agent-spine-demo/README.md | 15 +- .../examples/agent-spine-demo/package.json | 2 + .../examples/agent-spine-demo/src/index.ts | 21 +- .../agent-spine-demo/tests/agent-core.spec.ts | 41 + .../stdio-demo/tests/built-bin.e2e.ts | 2 +- packages/llm/llm/README.md | 2 +- .../helper/src/features/builtin/helpers.ts | 15 +- .../sdk/helper/src/features/builtin/spine.ts | 6 +- packages/sdk/helper/tests/project.spec.ts | 11 + .../tests/multi-subagent.spec.ts | 14 +- .../subagent-fork/tests/subagent-fork.spec.ts | 16 +- .../tests/structured.spec.ts | 14 +- .../tests/subagent-inprocess.spec.ts | 14 +- .../tests/subagent-spawn.spec.ts | 18 +- packages/support/invariants/README.md | 83 +- packages/support/invariants/package.json | 17 +- packages/support/invariants/src/index.ts | 544 ++++------- .../invariants/src/scoped-events.generated.ts | 71 -- .../invariants/tests/invariants.spec.ts | 851 ------------------ .../support/invariants/tests/service.spec.ts | 266 ++++++ packages/support/invariants/tsconfig.json | 23 +- packages/ui/acp/tests/config-options.spec.ts | 14 +- .../tests/integration.spec.ts | 14 +- pnpm-lock.yaml | 40 +- scripts/check-workspace-constraints.ts | 21 + scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 12 +- scripts/gen-scoped-events.ts | 68 +- tsconfig.base.json | 5 + website/.vitepress/config/api-sidebar.json | 4 + website/zh-CN/api/harness/invariants.md | 32 + 83 files changed, 2225 insertions(+), 1557 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md create mode 100644 packages/core/agent-loop/src/invariant.ts create mode 100644 packages/core/agent-loop/tests/invariant.spec.ts create mode 100644 packages/core/agent-loop/tsdown.config.ts create mode 100644 packages/core/agent/src/invariant.ts create mode 100644 packages/core/agent/tests/invariant.spec.ts create mode 100644 packages/core/agent/tsdown.config.ts create mode 100644 packages/core/scope/src/invariant.ts create mode 100644 packages/core/scope/src/scoped-events.generated.ts create mode 100644 packages/core/scope/tests/invariant.spec.ts create mode 100644 packages/core/scope/tsdown.config.ts create mode 100644 packages/core/session/src/invariant.ts create mode 100644 packages/core/session/tests/invariant.spec.ts create mode 100644 packages/core/session/tsdown.config.ts delete mode 100644 packages/support/invariants/src/scoped-events.generated.ts delete mode 100644 packages/support/invariants/tests/invariants.spec.ts create mode 100644 packages/support/invariants/tests/service.spec.ts create mode 100644 website/zh-CN/api/harness/invariants.md diff --git a/docs/architecture.md b/docs/architecture.md index fd21648dd2..1035407375 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,10 +1,10 @@ # DeepSeek Harness Architecture -The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is simple: **everything is a plugin**. The shipped loop is one plugin, not a privileged kernel. +The **DeepSeek Harness SDK** builds on Cordis: **everything is a plugin**, including the shipped loop. ## Overview -A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompt, tool, provider, adapter, and listener registrations. +A harness is a [Cordis](cordis-primer.md) context with services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and prompt/tool/provider/adapter/listener registrations. `packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins. @@ -37,6 +37,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus exact reads and relationship traces | +| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | registry and package-name selection for package-owned runtime checks | ## Event @@ -134,7 +135,7 @@ Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, re The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream. -**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, headers by folding `request/header` — and dev invariants assert this ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` plus the header's prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 58641456b1..883e747723 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -24,6 +24,8 @@ flowchart LR pkg_session_query["session-query"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + svc_invariants["ctx.invariants
Package-owned invariant registry"] + pkg_scope["scope"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] @@ -109,6 +111,7 @@ flowchart LR pkg_compact_basic --> svc_compact pkg_fs --> svc_fs pkg_fs_local --> svc_fs + pkg_invariants --> svc_invariants pkg_llm --> svc_llm pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm @@ -147,7 +150,6 @@ flowchart LR svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_cli_demo - svc_agents --> pkg_invariants svc_agents --> pkg_stdio_demo svc_agents --> pkg_subagent_inprocess svc_approval --> pkg_tool_bash @@ -158,6 +160,10 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_compact --> pkg_compact_basic svc_fs --> pkg_tool_fs + svc_invariants --> pkg_agent + svc_invariants --> pkg_agent_loop + svc_invariants --> pkg_scope + svc_invariants --> pkg_session svc_llm --> pkg_agent_loop svc_llm --> pkg_compact_basic svc_permission --> pkg_acp @@ -171,7 +177,6 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_cli_demo - svc_sessions --> pkg_invariants svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_subagent_inprocess @@ -208,14 +213,15 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 225098e851..76663117b8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -115,7 +115,8 @@ Source: [`packages/core/agent-loop/src/index.ts:369`](../packages/core/agent-loo * `dshHome` to bash environment and local skill discovery, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Owner schemas supply defaults for optional input; + * plugins this bundle owns. `invariants` configures global and package-filtered + * relational checks. Owner schemas supply defaults for optional input; * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their @@ -142,6 +143,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task control-tool wait bounds. */ toolTasks?: toolTasks.Config + /** Global enablement and package-name filters for invariant companions. */ + invariants?: InvariantConfig } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -155,9 +158,9 @@ export interface SkillConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:62`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -379,6 +382,22 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) +## `@deepseek-ai/dsh-invariants` + +```ts config-catalog +/** Runtime invariant selection configured on the service plugin. */ +export interface Config { + /** Global switch; defaults to `true`. */ + readonly enabled?: boolean + /** Case-sensitive JavaScript regex sources that admit package names; empty admits all. */ + readonly package_allowlist?: string[] + /** Case-sensitive JavaScript regex sources that exclude package names after allowlist matching. */ + readonly package_blocklist?: string[] +} +``` + +Source: [`packages/support/invariants/src/index.ts:15`](../packages/support/invariants/src/index.ts) + ## `@deepseek-ai/dsh-jsonrpc` Requires: `agents` @@ -1458,7 +1477,6 @@ 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/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..5a868e1eff 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -479,6 +479,24 @@ Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](.. Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) +## `ctx.invariants` — `InvariantService` + +Package-owned invariant registry with global and regex-based selection. + +```ts cordis-catalog +/** + * Register one package's invariant installer. The package name is reserved + * even when filtering disables its checks. Enabled installers run in a child + * fiber; failure disposes that fiber and releases the reservation. + * @param packageName - full npm package name that owns the contribution. + * @param installer - synchronous listener installer for the child context. + * @returns an effect-scoped disposer for the registration. + */ +register(packageName: string, installer: InvariantInstaller): () => void +``` + +Source: [`packages/support/invariants/src/index.ts:94`](../../packages/support/invariants/src/index.ts) + ## `ctx.llm` — `LlmService` The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 3668191fb2..2dc5a1c34e 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -502,7 +502,7 @@ interface TurnEndReasonMap { ## The turn-enclosure invariant -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). +Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ## Plugin-contributed log-only events @@ -512,6 +512,6 @@ The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepse ## Durability contract -What a persistence backend relies on: the durable log persists every event verbatim, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting the invariants plugin checks, is a breaking change to the on-disk format. +What a persistence backend relies on: the durable log persists every event verbatim, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format. The backends that consume this contract are on [persistence.md](persistence.md). diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..29f8763460 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -19,7 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | @@ -27,10 +27,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm-replay`](../packages/support/llm-replay) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -54,7 +54,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | [`invariants`](../packages/support/invariants) | +| `internal/dispatch` | - | [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | `internal/status` | - | [`agent`](../packages/core/agent) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 0886877f0e..af8504be18 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -151,12 +151,14 @@ flowchart TD pkg_workflow_workerthread["workflow-workerthread"] end pkg_llm --> pkg_brand + pkg_scope --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_helper --> pkg_brand pkg_scripts --> pkg_app_boot pkg_llm_deepseek --> pkg_llm pkg_llm_pi_ai --> pkg_llm pkg_session --> pkg_brand + pkg_session --> pkg_invariants pkg_session --> pkg_llm pkg_session --> pkg_scope pkg_system_prompt --> pkg_llm @@ -168,6 +170,7 @@ flowchart TD pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session pkg_agent --> pkg_brand + pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope pkg_agent --> pkg_session @@ -211,10 +214,6 @@ flowchart TD pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence - pkg_invariants --> pkg_agent - pkg_invariants --> pkg_llm - pkg_invariants --> pkg_scope - pkg_invariants --> pkg_session pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand pkg_user_approval --> pkg_llm @@ -247,6 +246,7 @@ flowchart TD pkg_permission --> pkg_session pkg_permission --> pkg_user_approval pkg_agent_loop --> pkg_agent + pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session @@ -391,6 +391,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_scope pkg_agent_spine_demo --> pkg_session pkg_agent_spine_demo --> pkg_skill pkg_agent_spine_demo --> pkg_skill_local @@ -451,27 +452,28 @@ flowchart TD | [`paths`](../packages/util/paths) | `util` | — | | [`retention`](../packages/util/retention) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | -| [`scope`](../packages/core/scope) | `core` | — | | [`skill`](../packages/skill/skill) | `skill` | — | | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | +| [`invariants`](../packages/support/invariants) | `support` | — | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | — | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | +| [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | | [`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) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`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) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`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) | +| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`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) | @@ -492,7 +494,6 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`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) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | @@ -501,7 +502,7 @@ flowchart TD | [`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) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`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), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -528,7 +529,7 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`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) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 4476c47a32..c7e542276e 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -167,6 +167,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Initiating Agent scope over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-initiator-scope.md) | 2026-07-15 | | [Advisory LLM catalogs and per-session ACP model selection](implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) | 2026-07-15 | | [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 | +| [Package-owned invariant service seam](implemented/architecture/2026-07-19-package-owned-invariant-service.md) | 2026-07-19 | ### Process 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 aac3991f46..9e272456fc 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 @@ -28,11 +28,11 @@ This guarantee belongs in `Session`, not in an optional listener, because every `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 +### Package-owned invariant companions check 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. +`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Optional `./invariant` companions from `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` own the rules that require trace state or observation of another seam: 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. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)). -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. +When the session companion attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. The service gives each contribution a disposable child fiber, so hot reload is safe in the middle of a turn without giving diagnostics ownership of session storage. ## Alternatives considered @@ -53,6 +53,6 @@ Detaching `deriveMessages()` would protect the most common request path but leav - 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. +- Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability. +- `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package. - 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-06-11-structured-error-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md index 01e50da2ff..f1114c62de 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md @@ -10,7 +10,7 @@ Failures crossed seams as bare strings. A tool error flattened to a text block A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every other imports — no new dependency edge): a stable `code` distinct from `message`, `cause` chaining via `ErrorOptions`, and `name` defaulting to the subclass. `isHarnessError` narrows at seams. -- `LlmError`, `ToolArgsError` (dsh-tools), and `InvariantError` (dsh-invariants) now extend it, keeping their existing codes. +- `LlmError` and `ToolArgsError` (dsh-tools) extend it, keeping their existing codes. - `ToolExecutionResult` gains optional `error: { name, code }`, populated in the registry's catch when the thrown value is a `HarnessError`. The agent loop forwards it onto the `tool/result` session event (which gained the same optional field), so the structured failure survives into the log for retry/sandbox plugins and replay. The model-facing text block is unchanged. - The loop's `toError` wraps a non-Error throw in a `HarnessError` (`code: 'UNKNOWN'`, original chained as `cause`) instead of a bare `Error`, so even a bad throw carries a routable code into the session `error` event (which already surfaced `code`). @@ -19,6 +19,6 @@ A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every - Errors are machine-routable end-to-end: a plugin can branch on `error.code` rather than substring-matching a message. - One base class is imported widely, but it lives in the package everyone already depends on, so the cost is a single import, not a new edge. - `deriveMessages` does not surface `error` into model history — the model still sees the text block; the structured field is for code and replay. -- Argument validation and dev invariants retain their existing codes and behavior; the shared base adds cross-seam routing metadata without changing model-facing text. +- Argument validation retains its existing code and behavior; package-owned diagnostic invariants carry their stable code independently so the invariant registry does not import a product package. The shared base adds cross-seam routing metadata without changing model-facing text. diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index a54f735219..81b1bd25df 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -21,7 +21,7 @@ In case 2, if the injected `context/message` is the last event before a flush/di - An `agent.inject()` made while the agent is **running** joins the already-open turn. While the current step executes assistant tool calls, accepted context waits in arrival order until that batch settles, then appends after every recorded result and before the turn closes even when execution is interrupted. - An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`. - The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number. -- The `dsh-invariants` plugin **enforces** the invariant in dev: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError`. +- The optional `dsh-session/invariant` companion registers the dev check with `ctx.invariants`: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError` attributed to `@deepseek-ai/dsh-session`. The serializability invariant is enforced at the same source boundary (`Session.append` throws on non-JSON-serializable data), so "what may enter the log" is now governed in one place rather than discovered downstream by whichever backend happens to be watching. diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index d639fe2917..55ba8cce4a 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -47,7 +47,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls ### Invariants -The dev-mode invariants plugin validates: `sourceEventSeqs` references (only `assistant/message` may use an empty list; otherwise no duplicates, references earlier events, and references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows). +`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: only `assistant/message` may use an empty provenance list; references are unique, earlier, and known; replacement endpoints exist in surface order; and provenance covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions. Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy. @@ -63,7 +63,6 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d - **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). - **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance. - **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration). -- **`packages/support/invariants`**: Surface-related validation rules. - **`packages/session-persistence/session-persistence-jsonl`**: No changes required. - **`packages/session-persistence/session-persistence`**: Abstract interface unchanged. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index 9b53369086..e86d6b9610 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session **`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. -**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. +**Enforcement.** In development, the optional `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 2c3126793f..e7e43cc1eb 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -322,7 +322,7 @@ TypeScript cannot govern JavaScript casts, direct Cordis dispatch, process messa ### Runtime invariants cover cross-service facts -The invariants plugin verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. Session trace validation stages before append commit and advances after the same event commits. +The optional `dsh-scope/invariant` companion verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. The separate `dsh-session/invariant` contribution stages trace validation before append commit and advances after the same event commits; both register through `ctx.invariants`. The plugin does not police trusted setup by scanning registries or reject prompt assembly objects fabricated through casts. Those checks would turn composition contracts into speculative runtime machinery without protecting a real external boundary. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml new file mode 100644 index 0000000000..ce8fabdc40 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-package-owned-invariant-service.md: f147cd91134509e17df39d1acbb28ae8b521c2f8 +2026-07-19-package-owned-invariant-service.zh.md: a106b57137c36009d0d4b4d39f109b7abf857f42 diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md new file mode 100644 index 0000000000..f147cd9113 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -0,0 +1,97 @@ +# RFC: Package-owned invariant service seam + +Status: implemented + +English | [中文](2026-07-19-package-owned-invariant-service.zh.md) + +## Problem + +Runtime invariant checks span session traces, agent state, scoped dispatch, and request reconstruction. Putting all checks in one diagnostics package makes that package import product vocabularies from unrelated domains, centralizes tests away from their owners, and requires the central package to change whenever a product package adds or removes a check. + +Deployments also need more than presence or absence of one plugin. A standard composition should carry the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently. + +## Decision + +### One registry service, package-owned contributions + +`@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. + +Packages expose diagnostics through optional `./invariant` companion plugins. Their root entrypoints do not import or register diagnostics implicitly, so loading a product package does not change runtime checking or require the invariant service. + +### Configuration and selection + +```ts +interface Config { + enabled?: boolean + package_allowlist?: string[] + package_blocklist?: string[] +} +``` + +Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. For a full registration name, selection is: + +```ts +export function selected(enabled: boolean, package_allowlist: RegExp[], package_blocklist: RegExp[], packageName: string): boolean { + return enabled + && ( + package_allowlist.length === 0 + || package_allowlist.some(pattern => pattern.test(packageName)) + ) + && !package_blocklist.some(pattern => pattern.test(packageName)) +} +``` + +Blocklist matches override allowlist matches. Each list entry is a case-sensitive JavaScript regex source compiled by `new RegExp(pattern)`. Matching is unanchored unless callers supply `^` and `$`; slash-delimited syntax and flags are not interpreted. Startup rejects blank, whitespace-padded, invalid, or duplicate sources within either list. A source that matches no loaded package remains valid because registration order, later loading, and HMR must not change config validity. + +### Registration and failure ownership + +The public registration boundary is `ctx.invariants.register(packageName, installer)`. It reserves one active registration per full npm package name even when filters disable installation, and returns the effect disposer. Disposing the companion or service releases the reservation and all contribution state. + +An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. + +Registration setup is transactional. If an installer fails after registering listeners, the child fiber is disposed completely and the name reservation is released before the failure escapes. Filtered registrations create no child but retain their reservation until disposal. Reloading a companion therefore begins with one clean installer state; stateful contributions rebuild baselines from their owning services. + +The former functional-plugin entrypoint and one-argument `InvariantError` constructor are not retained as compatibility surfaces. The repository is pre-release and all call sites move to the service and package-attributed error together. + +### Shipped companions + +| Companion entry | Registration name | Owned checks | +|---|---|---| +| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | session sequence, turn/step enclosure, and same-step call/result trace | +| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent-status transitions | +| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | +| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | + +Each owner contains its source and focused tests. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape. The service package retains only registry tests. + +### Scoped-event semantic map + +The generated scoped-event subject resolver lives in `dsh-scope`, beside the contract and invariant that consume it. `gen-scoped-events` uses the root TypeScript Program to enumerate `this: Scoped` declarations, infer routing-key types from real `scopeTarget(base, key)` calls, and require one unambiguous payload subject or an explicit unsupported marker. The committed runtime map imports no event-owner package, so semantic completeness does not expand either the service or scope package's runtime closure. + +### Standard composition and SDK output + +The standard agent spine mounts the service and all four companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. + +Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication surface. Generated config catalogs, module graphs, and API documentation derive from those sources. + +## Testing + +Service tests cover defaults, global disablement, allow/block selection, blocklist precedence, anchoring, unanchored matching, case sensitivity, invalid configuration, zero-match patterns, late registration, duplicate ownership, disposal, rollback, and HMR re-registration. Owner tests keep each invariant's positive and negative behavior beside its source. + +Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. + +## Alternatives considered + +- **Keep all checks in `dsh-invariants`.** Rejected because the registry would continue importing every checked product domain, owner changes would require central edits, and package tests would remain detached from the contracts they protect. +- **Let root package entrypoints register checks implicitly when `ctx.invariants` happens to exist.** Rejected because root behavior would depend on composition order and optional service presence, diagnostics could not be selected independently, and package loading would hide a registration effect outside an explicit companion. +- **Discover every `invariant.ts` file automatically.** Rejected because filesystem/package discovery is not a runtime ownership contract, makes bundled publication ambiguous, and cannot express explicit Cordis load order or dependency installation. +- **Validate allow/block entries against the currently loaded package set.** Rejected because a zero-match pattern can intentionally target a later or HMR-loaded contribution; current load order must not determine config validity. + +## Consequences + +- Product packages own and test their relational assertions while the service stays product-independent. +- Standard compositions can disable all checks or select package names without changing their plugin tree. +- Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. +- One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. +- Regex sources are deployment configuration and remain fixed until the service reloads. +- Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md new file mode 100644 index 0000000000..a106b57137 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -0,0 +1,97 @@ +# RFC: 包拥有的不变式服务接缝 + +Status: implemented + +[English](2026-07-19-package-owned-invariant-service.md) | 中文 + +## 问题 + +运行时不变式检查跨越会话轨迹、agent 状态、作用域 dispatch 和请求重建。如果所有检查都放在一个诊断包中,该包就必须导入彼此无关的产品领域词汇,测试也会离开真正的所有者;任何产品包新增或移除检查时,都要修改中央包。 + +部署还需要比“是否加载一个插件”更细的控制。标准组合应携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR 下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。 + +## 决策 + +### 一个注册服务,贡献归包所有 + +`@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 + +各包通过可选的 `./invariant` 伴随插件公开诊断。根入口不会隐式导入或注册诊断,因此加载产品包本身不会改变运行时检查,也不要求不变式服务存在。 + +### 配置与选择 + +```ts +interface Config { + enabled?: boolean + package_allowlist?: string[] + package_blocklist?: string[] +} +``` + +默认值为 `enabled: true`、`package_allowlist: []` 和 `package_blocklist: []`。对完整注册名的选择规则为: + +```ts +export function selected(enabled: boolean, package_allowlist: RegExp[], package_blocklist: RegExp[], packageName: string): boolean { + return enabled + && ( + package_allowlist.length === 0 + || package_allowlist.some(pattern => pattern.test(packageName)) + ) + && !package_blocklist.some(pattern => pattern.test(packageName)) +} +``` + +blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写的 JavaScript 正则表达式源,通过 `new RegExp(pattern)` 编译。除非调用方提供 `^` 与 `$`,否则匹配不锚定;系统不会解析斜杠包围语法或 flags。服务启动会拒绝空白、首尾带空白、无效或同一列表内重复的源。没有匹配当前已加载包的有效源仍然合法,因为注册顺序、稍后加载和 HMR 不应改变配置有效性。 + +### 注册与失败归属 + +公开注册边界是 `ctx.invariants.register(packageName, installer)`。即使过滤器禁止安装,它也会为每个完整 npm 包名保留唯一的活跃注册,并返回 effect disposer。卸载伴随插件或服务都会释放注册名及全部贡献状态。 + +启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 + +注册启动是事务性的。如果 installer 在注册监听器后失败,子 fiber 会完整释放,并在失败向外传播前解除包名占用。被过滤的注册不创建子 fiber,但会保留占用直到 dispose。伴随插件重载时总会从干净的 installer 状态开始;有状态贡献从其所属服务重建基线。 + +原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容表面保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。 + +### 已提供的伴随插件 + +| 伴随入口 | 注册名 | 所属检查 | +|---|---|---| +| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | 会话序号、turn/step 包围关系和同 step 的 call/result 轨迹 | +| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent 状态转换 | +| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | +| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | + +每个所有者都保存自己的源码与聚焦测试。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态。服务包只保留注册服务测试。 + +### Scoped event 语义映射 + +生成的 scoped event 主体解析表位于 `dsh-scope`,与消费它的契约和不变式相邻。`gen-scoped-events` 使用根 TypeScript Program 枚举 `this: Scoped` 声明,从真实 `scopeTarget(base, key)` 调用推断路由键类型,并要求唯一、无歧义的 payload 主体或显式 unsupported 标记。提交的运行时映射不导入事件所有者包,因此语义完整性不会扩大服务包或 scope 包的运行时依赖闭包。 + +### 标准组合与 SDK 输出 + +标准 agent spine 会挂载服务和四个伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。 + +Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一发布表面。生成的配置目录、模块图和 API 文档都从这些源派生。 + +## 测试 + +服务测试覆盖默认值、全局关闭、allow/block 选择、blocklist 优先级、锚定与非锚定匹配、大小写敏感、无效配置、零匹配模式、延迟注册、重复所有权、dispose、回滚和 HMR 重新注册。所有者测试把各不变式的正向与负向行为保留在其源码旁边。 + +组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 + +## 考虑过的替代方案 + +- **把所有检查保留在 `dsh-invariants`。** 不予采纳,因为注册包仍要导入所有被检查的产品领域,所有者变更仍需中央编辑,测试也继续远离被保护的契约。 +- **当 `ctx.invariants` 恰好存在时,让根包入口隐式注册检查。** 不予采纳,因为根入口行为会依赖组合顺序与可选服务是否存在,诊断无法独立选择,而且包加载会隐藏一个不在显式伴随插件中的注册 effect。 +- **自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。 +- **根据当前已加载包集合验证 allow/block 条目。** 不予采纳,因为零匹配模式可能有意指向稍后加载或 HMR 加载的贡献;当前加载顺序不能决定配置有效性。 + +## 后果 + +- 产品包拥有并测试自己的关系断言,服务保持与产品无关。 +- 标准组合无需改变插件树即可关闭全部检查或按包名选择。 +- 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 +- 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 +- 正则表达式源属于部署配置,在服务重载前保持固定。 +- 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 1d767d49fc..8e20795e40 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -8,7 +8,7 @@ A long-running agent conversation grows without bound. As the event log accumula The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — an ordered projection over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of entries and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. -Two forces shape the design. First, compaction policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../../implemented/architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. Second, `SurfaceEventType` is closed to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime. +Two forces shape the design. First, compaction policy and reusable token measurement vary independently: measurement belongs to the LLM-family [`ctx.tokenMeter` service](../../implemented/architecture/2026-07-15-replay-token-meter-service.md), while summarization can be a model call, a template, or a remote service. Second, `SurfaceEventType` is closed to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler and Session's always-on append/seed boundary reject `surfaceOp` on it. ## Decision @@ -114,7 +114,7 @@ Two failure paths, both documented: - **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, ordered event sequences, and rewrite generation. -- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement entry at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. +- **`dsh-session`** accepts `start > end` numerically when the named entries appear in valid surface position order: a head-anchored compaction can place a high-seq replacement entry at an older range's position. The optional session companion reuses turn enclosure unchanged. - **Wiring**: `examples/repl-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy. ## Testing diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md index 0c642e7c68..8fefb6726b 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -14,7 +14,7 @@ The obvious third option — let a plugin edit the request's `messages` on the w Three properties carry the design: -- **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The dev invariant ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)) recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire. +- **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The [`dsh-agent-loop/invariant`](../../../../packages/core/agent-loop/src/invariant.ts) companion recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire when that contribution is enabled. - **Frozen per instance.** Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (`agent.inject()` or tool/prompt-submit `additionalContexts` — [the interception-seams RFC](2026-06-30-interception-seams.md)), each a durable `context/message` paid once and prefix-cached thereafter. - **Exact in the durable request envelope.** Composition precedes the instance's first `agent/pre-step` and request boundary. The first routed request logs the current prefix on its header, so post-step token pressure reads the exact prefix together with the actual prompt, tools, and routed model; no compaction-only parameter is carried through the generic pre-step seam. A composition interrupted by cancel/dispose is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal. diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml index eb411f1158..72f9163933 100644 --- a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-typescript-program-backed-semantic-gates.md: 3e7a76e86d83080ae1a4f91ca97cc9749c90ef29 -2026-07-14-typescript-program-backed-semantic-gates.zh.md: 0a13452012e7f6cbd3ad7994845ba1985355c089 +2026-07-14-typescript-program-backed-semantic-gates.md: 427ffbf93a67a49664115f2376351c8da5afb9d1 +2026-07-14-typescript-program-backed-semantic-gates.zh.md: 56cba0141fdba98e0186093dca565674dd47cc9f diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md index 3e7a76e86d..427ffbf93a 100644 --- a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md +++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md @@ -38,9 +38,9 @@ Every declared harness event must have a discovered producer. A missing producer Exactly one match generates a resolver. Multiple matches are ambiguous and fail. Zero matches require `@dshScopeScan unsupported`, which is reserved for events whose routing key intentionally stays outside the payload, such as owner-keyed session events and parent-keyed subagent lifecycle events. The annotation records an unsupported scan; it does not encode an event name, parameter index, property path, or replacement type. -The committed [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) imports every scoped-event owner for its type-side `Events` contributions. Each generated lambda accepts `Parameters`, and the complete object satisfies a `Record` over the derived `ScopedEventName` union. Ordinary TypeScript compilation therefore checks event existence, parameter position, property access, and scoped-event completeness. The only cast adapts Cordis's runtime `unknown[]` dispatch boundary to the already type-checked resolver. +The committed [`scoped-events.generated.ts`](../../../../packages/core/scope/src/scoped-events.generated.ts) is a runtime-only map in the package that owns scoped dispatch and imports no event-owner package. Semantic completeness lives in the generator: its root Program enumerates every scoped `Events` declaration and real `scopeTarget` contract, resolves the unique payload path with the checker, and refuses missing, stale, or ambiguous entries before rendering the `unknown[]` runtime boundary. -The invariants plugin consumes this generated runtime map instead of maintaining its own table. Additional event-owner packages are dev dependencies and project references of `dsh-invariants`, not peer dependencies, so the compile-time aggregation does not expand the plugin's runtime closure. +The `dsh-scope/invariant` companion consumes this map instead of maintaining a handwritten table. Because Program analysis happens in the repository gate rather than through generated type imports, neither `dsh-scope` nor `dsh-invariants` acquires dependencies on every event owner. ### Semantic gaps fail explicitly @@ -48,7 +48,7 @@ The generators reject missing declarations, config diagnostics, widened or gener ## Verification -`verify-doc-graphs` freshness-checks semantic producer/listener discovery, and `verify-scoped-events` freshness-checks the generated resolver map. The root TypeScript build compiles the resolver against merged `Events`; workspace constraints and runtime-closure checks ensure its type-only aggregation does not become a deployment dependency. +`verify-doc-graphs` freshness-checks semantic producer/listener discovery, and `verify-scoped-events` reruns the Program analysis while freshness-checking the generated resolver map. The root TypeScript build compiles its runtime adapter; workspace constraints and runtime-closure checks keep event-owner aggregation out of deployment dependencies. ## Alternatives considered @@ -58,6 +58,6 @@ The generators reject missing declarations, config diagnostics, widened or gener - Event relation generation follows semantic receiver identity and closed event values instead of local naming conventions. - Scoped-event membership, subject extraction, and runtime invariant coverage come from event declarations and real dispatch contracts rather than handwritten tables. -- Refactors that change event names, parameter positions, subject properties, or routing-key types fail generation or compilation at the owning contract. +- Refactors that change event names, parameter positions, subject properties, or routing-key types fail generation at the owning contract. - Building a flattened Program costs more startup time and memory than parsing isolated files, and semantic gates depend on a valid root project graph. - Generated TypeScript remains committed source: changes to event owners or dispatch shapes must regenerate it and the affected documentation. diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md index 0a13452012..56cba0141f 100644 --- a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md +++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md @@ -38,9 +38,9 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件 恰好一个匹配项会生成解析函数。存在多个匹配项时,含义不明确,生成器会失败。没有匹配项时,事件必须标记 `@dshScopeScan unsupported`;该标记只用于路由键有意留在事件参数之外的情况,例如按所属 agent(智能体)路由的会话事件和按父 agent 路由的 subagent 生命周期事件。此标记只表示扫描不受支持,不编码事件名、参数下标、属性路径或替代类型。 -仓库提交的 [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) 会导入每个带作用域的事件声明方,使它们从类型侧合并进 `Events`。每个生成函数都接收 `Parameters`,完整对象则满足基于 `ScopedEventName` 联合类型派生出的 `Record`。因此,常规 TypeScript 编译会检查事件是否存在、参数位置、属性访问和带作用域的事件集合完整性。唯一的类型断言只负责将 Cordis 运行时的 `unknown[]` dispatch 边界适配到已经通过类型检查的解析函数。 +仓库提交的 [`scoped-events.generated.ts`](../../../../packages/core/scope/src/scoped-events.generated.ts) 是位于 scoped dispatch 所属包中的纯运行时映射,不导入任何事件声明方包。语义完整性由生成器自身保证:根 Program 枚举所有 scoped `Events` 声明与真实 `scopeTarget` 契约,通过 checker 解析唯一的 payload 路径,并在渲染 `unknown[]` 运行时边界前拒绝缺失、陈旧或含义不明确的条目。 -不变式插件消费这份生成的运行时表,不再维护自己的事件表。新增的事件声明方包只作为 `dsh-invariants` 的开发依赖和项目引用存在,不进入对等依赖,因此编译期聚合不会扩大插件的运行时依赖闭包。 +`dsh-scope/invariant` companion 消费这份映射,不再维护手写事件表。Program 分析发生在仓库门禁内,而不是依赖生成的类型导入,因此 `dsh-scope` 和 `dsh-invariants` 都不需要依赖所有事件声明方。 ### 语义缺口必须显式失败 @@ -48,7 +48,7 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件 ## 验证 -`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查,`verify-scoped-events` 对生成的解析函数表执行新鲜度检查。根 TypeScript 构建会将解析函数与合并后的 `Events` 一起编译;workspace 约束和运行时依赖闭包检查则确保仅参与类型聚合的依赖不会变成部署依赖。 +`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查;`verify-scoped-events` 会重新运行 Program 分析,并检查生成映射的新鲜度。根 TypeScript 构建编译其运行时适配器;workspace 约束与运行时依赖闭包检查确保事件声明方聚合不会进入部署依赖。 ## 考虑过的替代方案 @@ -58,6 +58,6 @@ Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件 - 事件关系生成依据语义接收者身份和封闭事件值,不再依赖局部命名约定; - 带作用域的事件成员关系、主体提取和运行时不变式覆盖来自事件声明与真实 dispatch 契约,不再来自手写表; -- 修改事件名、参数位置、主体属性或路由键类型时,会在其所属契约处触发生成或编译失败; +- 修改事件名、参数位置、主体属性或路由键类型时,会在其所属契约处触发生成失败; - 构建扁平化 Program 比解析孤立文件消耗更多启动时间和内存,语义门禁也依赖有效的根项目图; - 生成的 TypeScript 仍属于提交到仓库的源码:事件声明方或 dispatch 形态发生变化后,必须重新生成该文件和受影响的文档。 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index 9eb4c585a4..5db00851ae 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -28,7 +28,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim - **Six merge-extensible maps** (~370 LOC of core types): `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap` (in `dsh-llm`); `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap` (in `dsh-session`). - **~10 `declare module` augmentation sites** across `dsh-agent`, `dsh-agent-loop`, `dsh-bash`, `dsh-llm`, `dsh-session`, `dsh-session-persistence`, `dsh-system-prompt`, `dsh-tools` — each would move from declaration merging to a runtime `register()` call. - **The event producers** — 16 `session.append(...)` call sites in the loop — unchanged in shape but now validated at the boundary. -- **~7 switch-consumers** that branch on these unions: `deriveMessages` (`dsh-session`), `BlockAssembler` (`dsh-llm`), the `dsh-invariants` plugin, both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive. +- **~7 switch-consumers** that branch on these unions: `deriveMessages` and the optional invariant companion (`dsh-session`), `BlockAssembler` (`dsh-llm`), both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive. - **The `defineTool` `InferArgs` DSL** (`dsh-tools`), which derives zero-cast `execute` arg types from a compile-time schema spec — the showcase of the current approach. - **Docs**: architecture.md (the pattern is described as foundational), [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md), and any RFC that references the pattern. @@ -37,7 +37,7 @@ This is a repository-wide vocabulary redesign, not a persistence implementation ## Alternatives considered ### A. Status quo — merge-extensible types + `isJsonValue` at the durable boundary -Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility, enforced by TypeScript at compile time and by the `dsh-invariants` plugin's structural checks in dev. +Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility and is enforced by TypeScript at compile time. Package-owned invariant companions check selected cross-record relationships in dev but do not provide general runtime shape schemas. - **Pros**: zero churn; plugin extension is a one-line `interface` augmentation with full type inference and no runtime registration ceremony; no new runtime dependency; the `defineTool` DSL and `assertNever` exhaustiveness keep working. - **Cons**: no runtime structural validation at the persistence boundary or at plugin seams; a malformed-but-JSON datum is caught late. 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 1327f073c5..0410940127 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -8,7 +8,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session' @@ -95,10 +98,17 @@ class OverflowRecoveryAdapter extends LlmAdapter { } } +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService, { contextWindow: 400 }) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) @@ -223,7 +233,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () const ctx = new Context() const adapter = new OverflowRecoveryAdapter(delivery) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService, { contextWindow: 128 }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 52f8477318..9cbfd2a59b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -250,6 +250,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'invariants', + summary: 'Package-owned invariant registry with global and regex-based selection.', + methods: [ + { + signature: 'register(packageName: string, installer: InvariantInstaller): () => void', + jsDoc: '/**\n * Register one package\'s invariant installer. The package name is reserved\n * even when filtering disables its checks. Enabled installers run in a child\n * fiber; failure disposes that fiber and releases the reservation.\n * @param packageName - full npm package name that owns the contribution.\n * @param installer - synchronous listener installer for the child context.\n * @returns an effect-scoped disposer for the registration.\n */', + }, + ], + }, { key: 'llm', summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.', @@ -1176,6 +1186,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InjectOptions', declaration: 'export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}', }, + { + name: 'InvariantFailure', + declaration: 'export type InvariantFailure = (message: string) => never;', + }, + { + name: 'InvariantInstaller', + declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void;\n readonly inject?: Inject;\n}', + }, { name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index d604e2b16f..a015230ce0 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -27,6 +27,10 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo `agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services. +### Invariant companion + +The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. For each frozen loop-built request carrying a live session id, it independently rebuilds the message boundary and folded request header from the session log; direct one-shot calls remain outside this marker contract. + ### Configuration (schemastery) ```ts diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 7e2fb235a2..8e9a2b93bb 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -11,10 +11,15 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -22,6 +27,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts new file mode 100644 index 0000000000..41b9112a4e --- /dev/null +++ b/packages/core/agent-loop/src/invariant.ts @@ -0,0 +1,73 @@ +/** + * Package-owned request-reconstruction invariant for loop-built LLM calls. + * @module @deepseek-ai/dsh-agent-loop/invariant + */ + +import type { Context } from 'cordis' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop' + +/** Cordis companion plugin name. */ +export const name = 'agent-loop-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants', 'sessions'] + +/** Install the request-reconstruction contribution into its child registration fiber. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + // Prepend prevents a short-circuiting replay listener from silencing the + // check; correctness itself comes from the sequence-bounded reconstruction. + ctx.on('llm/stream', (options: GenerateOptions, next) => { + if (options.sessionId === undefined || !Object.isFrozen(options)) return next() + const session = ctx.sessions.get(options.sessionId) + if (!session) return next() + if (!Object.isFrozen(options.messages)) { + fail('a loop-built request must carry a frozen messages array') + } + + const events = session.events + let boundary = -1 + for (let index = events.length - 1; index >= 0; index -= 1) { + if (events[index]?.type === 'step/start') { + boundary = index + break + } + } + if (boundary === -1) { + return fail('a loop-built request with no step/start in its session log') + } + const header = foldRequestHeader(events) + if (header === undefined) { + return fail('a loop-built request with no request/header event in its session log') + } + const rebuilt = new Session( + SessionId(`${String(session.id)}-invariant-rebuild`), + structuredClone(events.slice(0, boundary)), + ) + const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()] + if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { + fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) + } + + const headerMatches = options.model === header.config.model + && options.system === header.system + && options.temperature === header.config.temperature + && options.maxTokens === header.config.maxTokens + && JSON.stringify(options.stop) === JSON.stringify(header.config.stop) + && JSON.stringify(options.tools ?? []) === JSON.stringify(header.tools ?? []) + if (!headerMatches) { + fail(`llm request for session "${String(session.id)}" diverges from the folded request header`) + } + return next() + }, { global: true, prepend: true }) +}, { inject: ['sessions'] }) + +/** + * Register the agent-loop invariant companion. + * @param ctx - Cordis context carrying the invariant and session services. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 80d085c32b..0146efbd1b 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -7,9 +7,19 @@ import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/ds import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + function driverDone(agent: Agent): Promise { return (agent as Agent & { done: Promise }).done } @@ -144,7 +154,7 @@ describe('successful provider completion survives agent/step-result failure', () ): Promise { const adapter = new MockAdapter([response]) const ctx = await harness(adapter) - await ctx.plugin(Invariants) + await mountInvariants(ctx) const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' }) const failure = new Error(`${id} result processing failed`) const reported: Error[] = [] @@ -1014,7 +1024,7 @@ describe('step boundary publication order', () => { }) describe('turn and step boundary recovery', () => { - // The invariants plugin makes an unbalanced log fail the test. + // The session invariant companion makes an unbalanced log fail the test. async function balancedHarness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -1023,7 +1033,7 @@ describe('turn and step boundary recovery', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) return ctx } @@ -1437,7 +1447,7 @@ describe('surface: assistant/message records exact empty provenance when no chun // stream from legacy events whose provenance was not recorded. const adapter = new MockAdapter([[]]) const ctx = await harness(adapter) - await ctx.plugin(Invariants) + await mountInvariants(ctx) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({ @@ -1474,7 +1484,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) // Parent-owned listener survives agent-fiber disposal. @@ -1525,7 +1535,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { @@ -1580,7 +1590,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('agent/pre-step', async () => { @@ -1631,7 +1641,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('agent/pre-step', async () => { @@ -1680,7 +1690,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants) + await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts new file mode 100644 index 0000000000..a61cda6f99 --- /dev/null +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentLoopInvariant) + return ctx +} + +function dispatch(ctx: Context, options: unknown): void { + void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never) +} + +async function requestSetup() { + 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' }) + const boundary = session.deriveMessages() + session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) + return { ctx, session, boundary } +} + +describe('request-reconstruction invariant', () => { + it('accepts a frozen request equal to the boundary derivation and folded header', async () => { + const { ctx, session, boundary } = await requestSetup() + const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) + expect(() => { dispatch(ctx, options) }).not.toThrow() + }) + + it('uses the step boundary rather than content appended afterward', async () => { + const { ctx, session, boundary } = await requestSetup() + session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) + const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) + expect(() => { dispatch(ctx, options) }).not.toThrow() + }) + + it('requires the folded session prefix ahead of derived history', async () => { + const { ctx, session, boundary } = await requestSetup() + const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } + session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' }) + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) }) + .not.toThrow() + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) + .toThrow(/diverges from the boundary derivation/) + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) }) + .toThrow(/diverges from the boundary derivation/) + }) + + it('rejects message and header divergence', async () => { + const { ctx, session, boundary } = await requestSetup() + const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }] + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) }) + .toThrow(/diverges from the boundary derivation/) + expect(() => { dispatch(ctx, Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) }) + .toThrow(/diverges from the folded request header/) + }) + + it('rejects loop requests with no boundary or header', async () => { + 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 }) + expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/) + session.append('step/start', { turn: 1, step: 1 }) + expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/) + }) + + it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => { + const { ctx, session, boundary } = await requestSetup() + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id })) }) + .toThrow(/frozen messages array/) + expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow() + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow() + expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) }) + .not.toThrow() + }) + + it('prepends ahead of a short-circuiting stream listener', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.on('llm/stream', () => (async function* () {})() as never) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentLoopInvariant) + const session = ctx.sessions.create(SessionId('prepend-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' }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) + const divergent = Object.freeze({ + model: 'm', + messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]), + sessionId: session.id, + }) + expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/) + }) +}) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 355e1e8e3d..736ada7013 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -7,9 +7,19 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmService) @@ -17,7 +27,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json index 5d7cf98bb7..0949f18453 100644 --- a/packages/core/agent-loop/tsconfig.json +++ b/packages/core/agent-loop/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/agent-loop/tsdown.config.ts b/packages/core/agent-loop/tsdown.config.ts new file mode 100644 index 0000000000..e92275a7f5 --- /dev/null +++ b/packages/core/agent-loop/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and optional invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index bf5e563443..574d865792 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -2,6 +2,8 @@ Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable. +The optional `@deepseek-ai/dsh-agent/invariant` companion registers this package's agent-status transition checks with `ctx.invariants`. The root agent service does not load diagnostics implicitly. + ## Service: `AgentRegistry` (ctx key: `agents`) Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package. diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index bcf75ea7ca..f40a5da441 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -31,6 +37,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts new file mode 100644 index 0000000000..1902f3e746 --- /dev/null +++ b/packages/core/agent/src/invariant.ts @@ -0,0 +1,35 @@ +/** Package-owned agent lifecycle invariants. @module @deepseek-ai/dsh-agent/invariant */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent' + +/** Cordis companion plugin name. */ +export const name = 'agent-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Install the agent contribution into its child registration fiber. */ +const install: InvariantInstaller = (ctx, fail) => { + const lastStatus = new WeakMap() + ctx.on('agent/status', (agent, status) => { + const previous = lastStatus.get(agent) + if (previous === status) { + fail(`agent/status repeated ${status} (no-op transition)`) + } + if (previous === 'disposed') { + fail(`agent/status left terminal state disposed → ${status}`) + } + lastStatus.set(agent, status) + }, { global: true }) +} + +/** + * Register the agent invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts new file mode 100644 index 0000000000..3c0d147b9a --- /dev/null +++ b/packages/core/agent/tests/invariant.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(AgentInvariant) + return ctx +} + +function mockAgent(id: string): Agent { + return { id } as unknown as Agent +} + +describe('agent status invariants', () => { + it('accepts lifecycle transitions through idle, running, and disposed', async () => { + const ctx = await setup() + const agent = mockAgent('a1') + expect(() => { + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') + }).not.toThrow() + + const running = mockAgent('a2') + ctx.emit(scopeTarget(running, running), 'agent/status', running, 'running') + expect(() => { ctx.emit(scopeTarget(running, running), 'agent/status', running, 'disposed') }).not.toThrow() + }) + + it('rejects a no-op transition', async () => { + 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() + 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 agents independently', async () => { + const ctx = await setup() + const a = mockAgent('a5') + const b = mockAgent('b5') + ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') + expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() + }) +}) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index 2692e1b7f7..b6d6c9e6bf 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/agent/tsdown.config.ts b/packages/core/agent/tsdown.config.ts new file mode 100644 index 0000000000..e92275a7f5 --- /dev/null +++ b/packages/core/agent/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and optional invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 36cc059bc4..3ee979bda0 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -13,6 +13,8 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c - `Scoped` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties. - `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. +The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls. + ## Design contract The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals. diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 88d78ceb8b..4679e2755a 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/scope/src/invariant.ts b/packages/core/scope/src/invariant.ts new file mode 100644 index 0000000000..a5bd59f263 --- /dev/null +++ b/packages/core/scope/src/invariant.ts @@ -0,0 +1,41 @@ +/** Package-owned scoped-dispatch invariants. @module @deepseek-ai/dsh-scope/invariant */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' +import { scopedSubjectResolverFor } from './scoped-events.generated.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-scope' + +/** Cordis companion plugin name. */ +export const name = 'scope-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Install the scoped-dispatch contribution into its child registration fiber. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.on('internal/dispatch', (_mode, eventName, args, thisArg) => { + const subjectOf = scopedSubjectResolverFor(eventName) + if (subjectOf === undefined) return + if (!isScopeCarrier(thisArg)) { + fail( + `"${eventName}" is a scope-filtered event but was dispatched without a scope carrier — ` + + 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))', + ) + } + if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) { + fail( + `"${eventName}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — ` + + 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))', + ) + } + }, { global: true }) +} + +/** + * Register the scope invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts new file mode 100644 index 0000000000..4e23706fb3 --- /dev/null +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -0,0 +1,50 @@ +/** + * Generated scoped-event routing-subject resolvers for dsh-scope invariants. + * Do not edit by hand; run `pnpm run gen-scoped-events`. + * + * @module @deepseek-ai/dsh-scope/scoped-events.generated + */ + +type ScopedSubjectResolver = (args: readonly unknown[]) => unknown + +const scopedSubjectResolvers: Readonly> = Object.freeze({ + 'agent/created': args => args[0], + 'agent/disposed': args => args[0], + 'agent/error': args => args[0], + 'agent/post-step': args => args[0], + 'agent/pre-step': args => args[0], + 'agent/prompt-submit': args => args[0], + 'agent/queued': args => args[0], + 'agent/request': args => args[0], + 'agent/request-error': args => args[0], + 'agent/session-prefix': args => args[0], + 'agent/session-start': args => args[0], + 'agent/status': args => args[0], + 'agent/step-result': args => args[0], + 'agent/turn-continuation': args => args[0], + 'agent/turn-stop': args => args[0], + 'approval/request': args => (args[0] as Record)['agent'], + 'session/created': null, + 'session/disposed': null, + 'session/event': null, + 'session/flush': null, + 'subagent/end': null, + 'subagent/start': null, + 'system-prompt/assemble': args => (args[1] as Record)['scope'], + 'tools/execute': args => (args[0] as Record)['agent'], + 'tools/post-execute': args => (args[0] as Record)['agent'], + 'tools/pre-execute': args => (args[0] as Record)['agent'], + 'tools/result': args => (args[0] as Record)['agent'], +}) + +/** + * Resolve the routing key named by one scoped event payload. A null + * resolver means the payload cannot expose its external routing key, so the + * invariant checks carrier presence only. + * @param event - runtime Cordis event name. + * @returns the generated subject resolver, null for presence-only, + * or undefined when the event is not scope-filtered. + */ +export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined { + return scopedSubjectResolvers[event] +} diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts new file mode 100644 index 0000000000..6e39ab2025 --- /dev/null +++ b/packages/core/scope/tests/invariant.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(ScopeInvariant) + return ctx +} + +function emit(ctx: Context, receiver: object | undefined, event: string, args: unknown[]): void { + const dispatch = ctx.emit.bind(ctx) as (...values: unknown[]) => void + if (receiver === undefined) dispatch(event, ...args) + else dispatch(receiver, event, ...args) +} + +describe('scoped-dispatch invariants', () => { + it('ignores ordinary events and rejects a scoped dispatch without a carrier', async () => { + const ctx = await setup() + expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow() + const agent = { id: 'a1' } + expect(() => { emit(ctx, undefined, 'agent/error', [agent, 1, 0, new Error('x')]) }) + .toThrow(/dispatched without a scope carrier/) + }) + + it('checks every generated subject resolver against the carrier key', async () => { + const ctx = await setup() + const agent = { id: 'a1' } + const other = { id: 'a2' } + const rows: Array<[string, unknown[]]> = [ + ['agent/created', [agent]], + ['agent/disposed', [agent]], + ['agent/error', [agent, 1, 0, new Error('x')]], + ['agent/post-step', [agent, 1, 1]], + ['agent/pre-step', [agent, 1, 1, new AbortController().signal]], + ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], + ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], + ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], + ['agent/request-error', [agent, 1, 1, new Error('x')]], + ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]], + ['agent/session-start', [agent, 'startup']], + ['agent/status', [agent, 'idle']], + ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]], + ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]], + ['agent/turn-stop', [agent, 1]], + ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], + ['system-prompt/assemble', [[], { scope: agent }]], + ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], + ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], + ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], + ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]], + ] + + for (const [event, args] of rows) { + expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} matching`).not.toThrow() + expect(() => { emit(ctx, scopeTarget(agent, other), event, args) }, `${event} mismatched`) + .toThrow(/DIFFERENT subject/) + } + }) + + it('requires carriers for generated presence-only scoped events without comparing a payload subject', async () => { + const ctx = await setup() + const agent = { id: 'a1' } + const rows: Array<[string, unknown[]]> = [ + ['session/created', [{}]], + ['session/disposed', [{}]], + ['session/event', [{}, {}]], + ['session/flush', [{}]], + ['subagent/end', [{}]], + ['subagent/start', [{}]], + ] + for (const [event, args] of rows) { + expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} carrier`).not.toThrow() + expect(() => { emit(ctx, undefined, event, args) }, `${event} no carrier`) + .toThrow(/dispatched without a scope carrier/) + } + }) +}) diff --git a/packages/core/scope/tsconfig.json b/packages/core/scope/tsconfig.json index 754725418e..9966c8ca8a 100644 --- a/packages/core/scope/tsconfig.json +++ b/packages/core/scope/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/scope/tsdown.config.ts b/packages/core/scope/tsdown.config.ts new file mode 100644 index 0000000000..1dbbf38372 --- /dev/null +++ b/packages/core/scope/tsdown.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and optional invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + // Preserve the root entry's carrier WeakMap identity across bundles. + deps: { neverBundle: ['@deepseek-ai/dsh-scope'] }, + }, +]) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index ba4e8ea94a..ee46c17590 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -2,6 +2,8 @@ Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered projection of message-producing events) is maintained on top of the raw log for efficient derivation and compaction. +The optional `@deepseek-ai/dsh-session/invariant` companion registers this package's relational trace checks with `ctx.invariants`: monotonic sequence numbers, turn/step enclosure, and same-step tool call/result pairing. It replays existing sessions when loaded or reloaded; storage validation, snapshotting, freezing, provenance, and surface acceptance remain always-on responsibilities of the root session package. + ## Service: `SessionStore` (ctx key: `sessions`) Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle. @@ -34,7 +36,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. - `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback. -- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. +- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks. - `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 540c5cdc75..314eb6e0b5 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,12 +28,14 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts new file mode 100644 index 0000000000..2887d6f9f0 --- /dev/null +++ b/packages/core/session/src/invariant.ts @@ -0,0 +1,230 @@ +/** + * Package-owned relational invariants for the session event log. Load this + * companion beside `@deepseek-ai/dsh-invariants` to enable the checks. + * + * @module @deepseek-ai/dsh-session/invariant + */ + +import type { Context } from 'cordis' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session' + +/** Cordis companion plugin name. */ +export const name = 'session-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants', 'sessions'] + +/** Per-session bookkeeping for relational log checks. */ +interface SessionTrace { + lastSeq: number + openTurn: number | null + openStep: number | null + nextTurn: number + nextStep: number + pendingCalls: Set +} + +/** One accepted event's deferred mutation of a committed session trace. */ +interface SessionTraceTransition { + scalars: Pick + pendingCalls: + | { kind: 'none' } + | { kind: 'add' | 'delete'; callId: CallId } + | { kind: 'clear' } +} + +/** Assert that a step-scoped event names the currently open turn and step. */ +function requireOpenStep( + trace: SessionTrace, + kind: string, + turn: number, + step: number, + fail: InvariantFailure, +): void { + if (trace.openTurn !== turn || trace.openStep !== step) { + fail(`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`) + } +} + +/** Validate one candidate event without mutating the committed trace. */ +function validateEvent( + trace: SessionTrace, + event: SessionEvent, + fail: InvariantFailure, +): SessionTraceTransition { + if (event.seq <= trace.lastSeq) { + fail(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`) + } + let openTurn = trace.openTurn + let openStep = trace.openStep + let nextTurn = trace.nextTurn + let nextStep = trace.nextStep + let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } + + // SessionEventMap is merge-extensible, so the default enforces turn + // enclosure for package-added events as well as the built-in variants. + switch (event.type) { + case 'turn/start': { + if (trace.openTurn !== null) { + fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`) + } + if (event.data.turn !== trace.nextTurn) { + fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`) + } + openTurn = event.data.turn + nextStep = 1 + break + } + case 'turn/end': { + if (trace.openTurn !== event.data.turn) { + fail(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`) + } + if (trace.openStep !== null) { + fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`) + } + openTurn = null + nextTurn += 1 + break + } + case 'step/start': { + if (trace.openTurn !== event.data.turn) { + fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`) + } + if (trace.openStep !== null) { + fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`) + } + if (event.data.step !== trace.nextStep) { + fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`) + } + openStep = event.data.step + break + } + case 'step/end': { + requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail) + pendingCalls = { kind: 'clear' } + openStep = null + nextStep += 1 + break + } + case 'assistant/chunk': { + requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail) + break + } + case 'assistant/message': { + requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step, fail) + break + } + case 'tool/call': { + requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step, fail) + pendingCalls = { kind: 'add', callId: event.data.callId } + break + } + case 'tool/result': { + requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail) + const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' + if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) { + fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`) + } + pendingCalls = { kind: 'delete', callId: event.data.callId } + break + } + default: { + if (trace.openTurn === null) { + fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`) + } + break + } + } + return { + scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep }, + pendingCalls, + } +} + +/** Apply one already-validated transition after its event commits. */ +function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void { + Object.assign(trace, transition.scalars) + switch (transition.pendingCalls.kind) { + case 'none': + break + case 'add': + trace.pendingCalls.add(transition.pendingCalls.callId) + break + case 'delete': + trace.pendingCalls.delete(transition.pendingCalls.callId) + break + case 'clear': + trace.pendingCalls.clear() + break + /* v8 ignore next -- validateEvent produces this closed transition union */ + default: + assertNever(transition.pendingCalls, 'session trace pending-call transition') + } +} + +/** Install the session contribution into its child registration fiber. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap() + const stagedTransitions = new WeakMap() + + const freshTrace = (): SessionTrace => ({ + lastSeq: -1, + openTurn: null, + openStep: null, + nextTurn: 1, + nextStep: 1, + pendingCalls: new Set(), + }) + + const seedSession = (session: Session): SessionTrace => { + const trace = freshTrace() + traces.set(session, trace) + for (const event of session.events) { + applyTransition(trace, validateEvent(trace, event, fail)) + } + return trace + } + + /* v8 ignore next -- session/event always follows list() or session/created seeding */ + const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session) + + for (const session of ctx.sessions.list()) seedSession(session) + + ctx.on('session/created', (session) => { seedSession(session) }, { global: true }) + + ctx.on('session/event', (session, event) => { + const staged = stagedTransitions.get(event) + /* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */ + if (staged === undefined || staged.session !== session) { + return fail('session/event reached publication without matching pre-commit validation') + } + stagedTransitions.delete(event) + applyTransition(staged.trace, staged.transition) + }, { global: true }) + + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const trace = traceFor(session) + const transition = validateEvent(trace, event, fail) + // A later dispatch listener may veto. Validation is pure, so abandoning + // this weakly keyed transition does not advance or retain the session. + stagedTransitions.set(event, { session, trace, transition }) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** + * Register the session invariant companion. + * @param ctx - Cordis context carrying the invariant and session services. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts new file mode 100644 index 0000000000..71f6dc9ace --- /dev/null +++ b/packages/core/session/tests/invariant.spec.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' +import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise<{ ctx: Context; fiber: Awaited> }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + const fiber = await ctx.plugin(SessionInvariant) + return { ctx, fiber } +} + +describe('session-log invariants', () => { + it('keeps registration global when the companion is mounted under a scope', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + let scopedCtx!: Context + await ctx.plugin(Object.assign((inner: Context) => { + scopedCtx = createScope(inner, {}).ctx + }, { inject: ['sessions', 'invariants'] })) + await scopedCtx.plugin(SessionInvariant) + const session = ctx.sessions.create(SessionId('global-under-scoped-invariants')) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + }) + + it('accepts a well-formed turn, step, and tool sequence', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }) + session.append('assistant/message', { + provenance: { provider: 'mock', model: 'mock' }, + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) + session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + }) + + it('does not advance committed trace state when a later dispatch listener vetoes', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create(SessionId('dispatch-veto-rollback')) + let veto = true + ctx.on('internal/dispatch', (_mode, name) => { + if (name !== 'session/event' || !veto) return + veto = false + throw new Error('later dispatch veto') + }) + expect(() => session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).toThrow('later dispatch veto') + expect(session.events).toEqual([]) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + }) + + it('applies the committed transition after another postcommit observer throws', async () => { + const { ctx } = await setup() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const session = ctx.sessions.create(SessionId('postcommit-peer')) + ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true }) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + expect(warnings).toHaveLength(2) + }) + + it('rejects non-monotonic event sequence numbers', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + 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) + expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, { + type: 'turn/end', + seq: 0, + time: 2, + data: { turn: 1, reason: { kind: 'completed' } }, + } as never) }).toThrow(/seq must strictly increase/) + }) + + it('enforces turn numbering and enclosure', async () => { + const first = await setup() + const open = first.ctx.sessions.create() + open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + .toThrow(/turn 1 is still open/) + expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } })) + .toThrow(/does not match open turn 1/) + + const second = (await setup()).ctx.sessions.create() + second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + second.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })) + .toThrow(/expected turn 2, got 3/) + + const outside = (await setup()).ctx.sessions.create() + expect(() => outside.append('user/message', { + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' })).toThrow(/outside any open turn/) + expect(() => outside.append('steering/message', { + turn: 1, + content: [{ type: 'text', text: 'go' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' })).toThrow(/outside any open turn/) + // Merge-extensible session events use the same default enclosure branch. + const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown + expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/) + }) + + it('enforces open-step identity and numbering', async () => { + const wrongTurn = (await setup()).ctx.sessions.create() + wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/) + + const nested = (await setup()).ctx.sessions.create() + nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + nested.append('step/start', { turn: 1, step: 1 }) + expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/) + expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } })) + .toThrow(/while step 1 is still open/) + expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/) + expect(() => nested.append('assistant/message', { + provenance: { provider: 'mock', model: 'mock' }, + turn: 1, + step: 2, + content: [], + }, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/) + + const skipped = (await setup()).ctx.sessions.create() + skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + skipped.append('step/start', { turn: 1, step: 1 }) + skipped.append('step/end', { turn: 1, step: 1 }) + expect(() => skipped.append('step/start', { turn: 1, step: 3 })) + .toThrow(/expected step 2 in turn 1, got 3/) + }) + + it('requires step-scoped stream and tool events to name the open step', async () => { + const chunk = (await setup()).ctx.sessions.create() + chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => chunk.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'x' }, + })).toThrow(/open is turn 1\/step null/) + + const tool = (await setup()).ctx.sessions.create() + tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + tool.append('step/start', { turn: 1, step: 1 }) + expect(() => tool.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('ghost'), + content: [], + isError: false, + }, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/) + }) + + it('allows interrupted repair results and unresolved calls at step end', async () => { + const repaired = (await setup()).ctx.sessions.create() + expect(() => { + repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + repaired.append('step/start', { turn: 1, step: 1 }) + repaired.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('crashed'), + content: [], + isError: true, + error: { name: 'InterruptedError', code: 'interrupted' }, + }, { surfaceOp: 'append' }) + repaired.append('step/end', { turn: 1, step: 1 }) + repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) + }).not.toThrow() + + const unresolved = (await setup()).ctx.sessions.create() + expect(() => { + unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + unresolved.append('step/start', { turn: 1, step: 1 }) + unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) + unresolved.append('step/end', { turn: 1, step: 1 }) + unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + }).not.toThrow() + }) + + it('does not let a result in a later step satisfy an earlier call', async () => { + 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 }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + expect(() => session.append('tool/result', { + turn: 1, + step: 2, + callId: CallId('c1'), + content: [], + isError: false, + }, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/) + }) + + it('replays seeded sessions and tracks each session independently', async () => { + 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: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, + ] + expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError) + + 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' } } }) + expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })) + .not.toThrow() + }) + + it('rebuilds trace state for sessions that exist when the companion reloads', async () => { + const { ctx, fiber } = 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 }) + await fiber.dispose() + await ctx.plugin(SessionInvariant) + expect(() => session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'h' }, + })).not.toThrow() + expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + .toThrow(/turn 1 is still open/) + }) + + it('removes all listeners when the companion is disposed', async () => { + const { ctx, fiber } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + expect(() => session.append('turn/start', { + turn: 2, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).not.toThrow() + }) +}) diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json index b19b98c5ad..253a1c8793 100644 --- a/packages/core/session/tsconfig.json +++ b/packages/core/session/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/session/tsdown.config.ts b/packages/core/session/tsdown.config.ts new file mode 100644 index 0000000000..e92275a7f5 --- /dev/null +++ b/packages/core/session/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and optional invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 409aef5ef2..de24d81a00 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -18,7 +18,12 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-skill-local local filesystem skill provider @deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events @deepseek-ai/dsh-tasks generic background-task registry -@deepseek-ai/dsh-invariants dev-mode event-contract assertions +@deepseek-ai/dsh-invariants configurable invariant registry service +@deepseek-ai/dsh-session/invariant +@deepseek-ai/dsh-agent/invariant +@deepseek-ai/dsh-scope/invariant +@deepseek-ai/dsh-agent-loop/invariant + package-owned relational checks @deepseek-ai/dsh-tool-bash the model-facing bash schema @deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @@ -42,11 +47,13 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. + +For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. ## Why a code bundle, not a shared YAML include @@ -59,4 +66,4 @@ Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and ## Known Limitations and Deferred Work - **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle. -- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate. +- **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate. diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 772d6c059b..cae0aab6d4 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-skill-local": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index a4965ca457..6849b21f2e 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -19,7 +19,11 @@ import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/d import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import AgentRegistry from '@deepseek-ai/dsh-agent' import TaskService from '@deepseek-ai/dsh-tasks' -import * as invariants from '@deepseek-ai/dsh-invariants' +import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants' +import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant' +import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import * as toolBash from '@deepseek-ai/dsh-tool-bash' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -48,7 +52,8 @@ export interface SkillConfig { * `dshHome` to bash environment and local skill discovery, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Owner schemas supply defaults for optional input; + * plugins this bundle owns. `invariants` configures global and package-filtered + * relational checks. Owner schemas supply defaults for optional input; * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their @@ -75,6 +80,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task control-tool wait bounds. */ toolTasks?: toolTasks.Config + /** Global enablement and package-name filters for invariant companions. */ + invariants?: InvariantConfig } /** The skill config schema exported for app packages that forward `skills`. */ @@ -101,7 +108,8 @@ export const Config = z.intersect([ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, toolTasks: ToolTasksConfigSchema, - }) as unknown as z>, + invariants: InvariantService.Config, + }) as unknown as z>, ]) as unknown as z /** @@ -120,6 +128,7 @@ export function pickSpineConfig(config: Omit): Omit { expect(ctx.get('skills')).toBeDefined() expect(ctx.get('agents')).toBeDefined() expect(ctx.get('tasks')).toBeDefined() + expect(ctx.get('invariants')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() await ctx.fiber.dispose() }) + it('mounts package companions and forwards invariant selection config', async () => { + const nestedTurn = (ctx: Context): void => { + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + } + + const enabled = await mount({ workspaceContext: false }) + expect(() => { nestedTurn(enabled) }).toThrow(/turn 1 is still open/) + await enabled.fiber.dispose() + + for (const invariants of [ + { enabled: false }, + { package_allowlist: ['^@deepseek-ai/dsh-agent$'] }, + { package_blocklist: ['^@deepseek-ai/dsh-session$'] }, + ]) { + const filtered = await mount({ workspaceContext: false, invariants }) + expect(() => { nestedTurn(filtered) }).not.toThrow() + await filtered.fiber.dispose() + } + }) + it('includes the skill registry, local provider, and skill tool without builtin skills', async () => { const ctx = await mount({ workspaceContext: false }) @@ -355,6 +382,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: {}, toolBash: { enableRunInBackground: false }, toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + invariants: { enabled: false }, } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ @@ -366,6 +394,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: {}, toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, + invariants: appConfig.invariants, }) expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false }) }) @@ -426,4 +455,16 @@ describe('dsh-agent-spine-demo bundle', () => { expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) + + it('keeps every invariant companion loadable through the real Loader unwrap path', () => { + const loader = Object.create(Loader.prototype) as Loader + for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) { + expect('default' in companion).toBe(false) + const unwrapped = loader.unwrapExports(companion) as Record + expect(unwrapped).toBe(companion) + expect(typeof unwrapped.name).toBe('string') + expect(unwrapped.inject).toContain('invariants') + expect(typeof unwrapped.apply).toBe('function') + } + }) }) diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index d3e0a32d09..18b3b9b33c 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -19,7 +19,7 @@ const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js') // Symlink each required workspace package by package name so plain Node resolves its built `main`, // matching an installed dependency rather than tsconfig paths. const dshPackages = [ - 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', + 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/scope', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 44e7d80a05..bd17fdc981 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -46,7 +46,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. -- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. +- `HarnessError` — base class for the product error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf product package every other product package imports, so a single base is shared without a new dependency edge. Per-package errors such as `LlmError` and `ToolArgsError` extend it. `isHarnessError(value)` narrows at seams. - `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. - `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. diff --git a/packages/sdk/helper/src/features/builtin/helpers.ts b/packages/sdk/helper/src/features/builtin/helpers.ts index 60f7530c61..9b21c7f64f 100644 --- a/packages/sdk/helper/src/features/builtin/helpers.ts +++ b/packages/sdk/helper/src/features/builtin/helpers.ts @@ -15,8 +15,19 @@ import type { PackageScriptResource, } from '../resources.ts' +/** Return the installable package name for a bare package or package subpath. */ +function installablePackageName(specifier: string): string { + const segments = specifier.split('/') + const expectedSegments = specifier.startsWith('@') ? 2 : 1 + if (segments.length < expectedSegments || segments.slice(0, expectedSegments).some(segment => segment.length === 0)) { + throw new Error(`invalid bare package specifier: ${JSON.stringify(specifier)}`) + } + return segments.slice(0, expectedSegments).join('/') +} + /** Create a runtime NPM dependency resource. */ -function npmDependency(_owner: string, name: string): NpmDependencyResource { +function npmDependency(_owner: string, specifier: string): NpmDependencyResource { + const name = installablePackageName(specifier) return { kind: 'npm-dependency', key: resourceKey(`npm-dependency:${name}`), @@ -52,7 +63,7 @@ export function cordisConfigEntry( } } -/** Couple one bare-package Cordis config entry to its mandatory runtime NPM dependency. */ +/** Couple one bare-package or subpath Cordis entry to its installable NPM package. */ export function npmCordisConfigEntry( owner: string, value: CordisConfigEntry, diff --git a/packages/sdk/helper/src/features/builtin/spine.ts b/packages/sdk/helper/src/features/builtin/spine.ts index caf066865e..3e1acb98fb 100644 --- a/packages/sdk/helper/src/features/builtin/spine.ts +++ b/packages/sdk/helper/src/features/builtin/spine.ts @@ -9,7 +9,7 @@ import type { ProjectProfile } from '../../project/types.ts' import { loadHelperTemplate } from '../../templates/template-assets.ts' import { FeatureOption, FixedFeature } from '../feature.ts' import { ProjectContribution } from '../resources.ts' -import { npmCordisConfigEntry, requiredString } from './helpers.ts' +import { cordisConfigEntry, npmCordisConfigEntry, requiredString } from './helpers.ts' const ID = featureId('spine') const PERSONA = loadHelperTemplate>('persona.txt.tpl').render({}).trimEnd() @@ -37,6 +37,10 @@ class SpineOption extends FeatureOption { ...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []), ...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }), ...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }), + cordisConfigEntry(ID, { id: 'session-invariant', name: '@deepseek-ai/dsh-session/invariant' }), + cordisConfigEntry(ID, { id: 'agent-invariant', name: '@deepseek-ai/dsh-agent/invariant' }), + ...npmCordisConfigEntry(ID, { id: 'scope-invariant', name: '@deepseek-ai/dsh-scope/invariant' }), + cordisConfigEntry(ID, { id: 'agent-loop-invariant', name: '@deepseek-ai/dsh-agent-loop/invariant' }), ...npmCordisConfigEntry(ID, { id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop', diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 655b308911..94fd212a68 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -188,9 +188,15 @@ describe('SdkProject and ProjectEditSession', () => { .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) + expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant') + expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant') + expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant') + expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant') expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}') expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2') expect(project.packageManifest().dependencies?.['@cordisjs/plugin-hmr']).toBe('^1.0.15') + expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1') + expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant') expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin') expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' }) expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL') @@ -865,6 +871,11 @@ describe('extension points', () => { expect(stringArray({ value: [1] }, 'value')).toHaveLength(1) expect(cordisConfigEntry('owner', { id: 'entry', name: 'pkg' }).ownedConfigKeys).toEqual([]) expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg' })[1].ownedConfigKeys).toEqual([]) + expect(npmCordisConfigEntry('owner', { id: 'entry', name: '@scope/pkg/subpath' })[0].name).toBe('@scope/pkg') + expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg/subpath' })[0].name).toBe('pkg') + for (const invalid of ['', '@scope', '@scope/']) { + expect(() => npmCordisConfigEntry('owner', { id: 'entry', name: invalid })).toThrow('invalid bare package specifier') + } expect(environmentResource('owner', 'EMPTY', undefined)).not.toHaveProperty('value') const builtins = createBuiltinRegistry(profile) expect(builtins.get(featureId('app')).defaultOptions(profile)).toEqual(['embed']) diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index df3b74d346..6f091c2bf6 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -3,7 +3,10 @@ import { Context } from 'cordis' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -11,6 +14,13 @@ import * as fork from '../src/index.ts' type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) { return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request }) } @@ -24,7 +34,7 @@ function start(ctx: Context, provider: string, request: Omit[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + function start(ctx: Context, provider: string, request: Omit & { signal?: AbortSignal }) { return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request }) } @@ -24,14 +34,14 @@ 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 plugin replays a seeded child log on + * real invariant service and package companions. The session contribution 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() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(fork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index e50457bcb4..58b83859c5 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -5,7 +5,10 @@ import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools' import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' @@ -18,6 +21,13 @@ import { type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + interface CodeRunRequestLike { bindings: { global: string; functions: Record Promise> }[] } @@ -51,7 +61,7 @@ async function setup(script: Script, options: SetupOptions = {}) { run: options.codeRun ?? (() => Promise.resolve({ logs: [] })), } as never) } - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const disposeProvider = ctx.subagents.registerProvider({ diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 13029ef3e7..3cba1b3a91 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -4,17 +4,27 @@ import { type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + async function setup(script: Script) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 7de5f6f4d6..7ba0551f34 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -5,7 +5,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' @@ -13,10 +16,17 @@ import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + /** * Drives the REAL spawn backend end-to-end: a real agent loop + a scripted mock * MODEL (the only mocked boundary) + the real SubagentService + the real - * dsh-invariants plugin (so a malformed child session log would fail the test). + * invariant service plus package companions (so a malformed child session log would fail the test). * The parent is a real config agent; the spawn provider creates a real child * agent on the same context and we assert its output. */ @@ -24,7 +34,7 @@ async function setup(script: Script) { const ctx = new Context() const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -295,7 +305,7 @@ describe('dsh-subagent-spawn', () => { const ctx = new Context() const adapter = new MockAdapter(['hang']) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 661e073d13..1cb0e57838 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -1,62 +1,63 @@ # dsh-invariants -Runtime event-contract assertions intended for development diagnostics. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests; it does not own or change product behavior. +Configurable registry service for package-owned runtime invariant checks. The root plugin registers `ctx.invariants`; it contains no product checks or product-package imports. Packages publish optional `./invariant` companion plugins that contribute their own assertions. -The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract. +## Service: `InvariantService` (`ctx.invariants`) -Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates the complete surface transition, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own. +```ts +interface Config { + enabled?: boolean + package_allowlist?: string[] + package_blocklist?: string[] +} +``` -Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only. +Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. A package is selected only when the service is enabled, the empty allowlist or at least one allowlist pattern matches its full npm name, and no blocklist pattern matches. Blocklist matches therefore override allowlist matches. -## Plugin +Each entry is a case-sensitive JavaScript regular-expression source compiled with `new RegExp(pattern)`. Matching is unanchored unless the source supplies `^` and `$`; `/pattern/flags` syntax is not parsed. Blank, whitespace-padded, invalid, or duplicate entries within one list fail service startup. A valid pattern may match no currently loaded package so later loading and HMR remain deterministic. -A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does): +`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Installer failure disposes the child and releases ownership atomically. + +The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes the listeners and reservation completely. A companion can therefore reload and register the same package name without retaining trace state or duplicate listeners; packages that need an existing baseline rebuild it during installation. + +`InvariantError` extends `Error`, carries stable `code: 'INVARIANT'`, and exposes the owning `packageName` without adding a product-package dependency to the service. + +## Package companions + +| Companion | Registration | Checks | +|---|---|---| +| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | sequence, turn/step enclosure, and same-step tool call/result trace | +| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent-status transitions | +| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | +| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | loop-built model-request reconstruction from the session log | + +The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency. + +## Composition ```ts import type { Context } from 'cordis' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' declare const ctx: Context -await ctx.plugin(Invariants) +ctx.plugin(InvariantService, { + enabled: true, + package_allowlist: ['^@deepseek-ai/dsh-'], + package_blocklist: ['^@deepseek-ai/dsh-agent-loop$'], +}) +ctx.plugin(SessionInvariant) ``` -`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. The oracle listeners are explicitly global so pre-commit staging and post-commit application keep the same audience even if the plugin is mounted under a scoped context; their cleanup still belongs to that mounting fiber. The plugin has no configuration. - -## Invariants asserted - -Session log (per session): - -- **`seq` strictly increases** — the spine of replay equivalence. -- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns. -- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. -- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. -- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal). -- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance. - -Agent status (per agent): - -- **legal transitions only** — `idle↔running` and `(idle|running)→disposed`. A no-op transition (`setStatus` dedups, so it never fires) and leaving the terminal `disposed` state are violations. - -Model requests (on `llm/stream`): - -- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the latest logged `request/header` (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing. - -On any violation it throws `InvariantError` (`code: 'INVARIANT'`). - -## Why runtime assertions remain useful - -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 wherever it is mounted 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 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. +The standard agent spine mounts the service and all four companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. ## Model Experience -None, as this observer only validates events and frozen requests and never rewrites prompts, schemas, messages, or streams. +None, as the service and companions observe runtime events and requests but never alter prompts, messages, schemas, streams, or tool results. ## Known Limitations and Deferred Work -- **The request-reconstructability assertion covers loop-built requests only** — hand-built one-shots (e.g. compaction's summarize call) carry no live `sessionId` marker and are skipped. -- **Merge-extended event families get no family-specific assertions** — `compact/*` lock pairing and `hook/*` invoked/result pairing are not checked here; only the core turn/step/chunk/tool-result contract is. +- The shipped checks cover only the four listed package contracts; a merge-extended event family has no family-specific assertion until its owner publishes one. +- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract. +- Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 59a425387b..e06e9a3db7 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-invariants", - "description": "Runtime event-contract assertions for DeepSeek Harness development diagnostics", + "description": "Registry service for package-owned DeepSeek Harness runtime invariants", "version": "0.0.1", "private": true, "type": "module", @@ -22,21 +22,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@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", "cordis": "^4.0.0-rc.7" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index cdb06edbac..754cb46ab9 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,409 +1,199 @@ /** - * Runtime listeners that fail loudly when cross-event contracts are broken: - * turn and step nesting, scoped dispatch, status transitions, and request - * reconstruction. The plugin has no environment guard and is active wherever - * mounted, including the default `dsh-agent-spine-demo` bundle; custom compositions - * may omit it. Sessions own immutable, surface-valid event storage; this plugin - * checks only relationships that event acceptance cannot express. + * Configurable registry for package-owned runtime invariant contributions. + * Packages register checks from optional `./invariant` companion plugins; + * ordinary package entrypoints stay independent of diagnostics. + * * @module @deepseek-ai/dsh-invariants */ -import type { Context } from 'cordis' -import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' -import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { scopedSubjectResolverFor } from './scoped-events.generated.ts' +import { Context, Service } from 'cordis' +import type { Inject } from 'cordis' +import z from 'schemastery' +import type Schema from 'schemastery' -export const name = 'invariants' -export const inject = ['sessions'] - -/** - * Thrown when a harness event-contract invariant is violated. Extends - * {@link HarnessError} (`code: 'INVARIANT'`) so a violation is routable like - * any other harness failure. - */ -export class InvariantError extends HarnessError { - constructor(message: string) { - super(`invariant violated: ${message}`, 'INVARIANT') - this.name = 'InvariantError' - } +/** Runtime invariant selection configured on the service plugin. */ +export interface Config { + /** Global switch; defaults to `true`. */ + readonly enabled?: boolean + /** Case-sensitive JavaScript regex sources that admit package names; empty admits all. */ + readonly package_allowlist?: string[] + /** Case-sensitive JavaScript regex sources that exclude package names after allowlist matching. */ + readonly package_blocklist?: string[] } -/** Per-session bookkeeping for the session-log invariants. */ -interface SessionTrace { - /** Highest `seq` seen so far (must strictly increase). */ - lastSeq: number - /** Open turn number, or null between turns. */ - openTurn: number | null - /** Open step within the current turn, or null between steps. */ - openStep: number | null - /** The next turn number expected in this session log. */ - nextTurn: number - /** The next step number expected within the open turn. */ - nextStep: number +/** + * Throw a package-attributed invariant failure. + * @param message - violated package contract without the standard prefix. + * @returns never because reporting a violation throws. + */ +export type InvariantFailure = (message: string) => never + +/** Install one package's listeners into the registration's child context. */ +export interface InvariantInstaller { /** - * Tool-call ids issued in the OPEN step awaiting a result. Cleared at - * `step/end` — a result must arrive in the same step as its call. + * Install the package contribution. + * @param ctx - child context owned by this invariant registration. + * @param fail - reporter bound to the registering package name. + * @returns nothing after synchronous listener installation completes. */ - pendingCalls: Set + (ctx: Context, fail: InvariantFailure): void + /** Services the child installer fiber may access. */ + readonly inject?: Inject } -/** One accepted event's deferred mutation of a live session trace. */ -interface SessionTraceTransition { - /** Scalar state after the event commits. */ - scalars: Pick - /** The event's mutation of the open step's pending call set. */ - pendingCalls: - | { kind: 'none' } - | { kind: 'add' | 'delete'; callId: CallId } - | { kind: 'clear' } +/** Internal effect shape used to join child startup before a companion loads. */ +interface PendingInvariantRegistration extends PromiseLike<() => void> { + (): void | Promise } -/** 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) { - throw new InvariantError( - `${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`, - ) +/** Thrown when a package-owned runtime invariant is violated. */ +export class InvariantError extends Error { + /** Stable machine-readable invariant failure code. */ + readonly code = 'INVARIANT' as const + /** Full npm package name that owns the violated invariant. */ + readonly packageName: string + + /** + * Construct a package-attributed invariant failure. + * @param packageName - full npm package name that registered the check. + * @param message - violated contract, without the standard error prefix. + */ + constructor(packageName: string, message: string) { + super(`invariant violated by "${packageName}": ${message}`) + this.name = 'InvariantError' + this.packageName = packageName } } -/** Validate one candidate event without mutating the committed session trace. */ -function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition { - // seq is strictly monotonic — the spine of replay equivalence. lastSeq - // starts at -1, so the first event (seq 0) passes. - if (event.seq <= trace.lastSeq) { - throw new InvariantError(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`) - } - let openTurn = trace.openTurn - let openStep = trace.openStep - let nextTurn = trace.nextTurn - let nextStep = trace.nextStep - let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } - - // Boundary/step-scoped events have explicit cases; every OTHER event type — - // including plugin-added (merge-extensible) SessionEventMap keys — is caught - // by the `default` and must be turn-enclosed (the turn-enclosure RFC). No assertNever: an - // unknown variant is valid, not a compile error. - switch (event.type) { - case 'turn/start': { - if (trace.openTurn !== null) { - throw new InvariantError(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`) - } - // Current sessions replay full logs, so numbering starts at 1 and remains - // contiguous. If a future compaction/fork stores a partial log, it must - // seed `nextTurn` from retained metadata before this check runs. - if (event.data.turn !== trace.nextTurn) { - throw new InvariantError(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`) - } - openTurn = event.data.turn - nextStep = 1 - break - } - case 'turn/end': { - if (trace.openTurn !== event.data.turn) { - throw new InvariantError(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`) - } - if (trace.openStep !== null) { - throw new InvariantError(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`) - } - openTurn = null - nextTurn += 1 - break - } - case 'step/start': { - if (trace.openTurn !== event.data.turn) { - throw new InvariantError(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`) - } - if (trace.openStep !== null) { - throw new InvariantError(`step/start ${event.data.step} while step ${trace.openStep} is still open`) - } - // Steps are checked under the same full-log assumption as turns above. - if (event.data.step !== trace.nextStep) { - throw new InvariantError(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`) - } - openStep = event.data.step - break - } - case 'step/end': { - requireOpenStep(trace, 'step/end', event.data.turn, event.data.step) - // A result must arrive in the step that issued the call; orphan calls - // (a step that errored before its result) do not carry to the next step. - pendingCalls = { kind: 'clear' } - openStep = null - nextStep += 1 - break - } - case 'assistant/chunk': { - requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step) - break - } - case 'assistant/message': { - requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step) - break - } - case 'tool/call': { - requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step) - pendingCalls = { kind: 'add', callId: event.data.callId } - break - } - case 'tool/result': { - requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step) - // A result needs a prior matching call in the same step. (The converse - // does NOT hold: a call may have no result — a throwing tool-execution - // pipeline step ends the turn with no tool/result, which is legal.) - const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' - if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) { - throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`) - } - pendingCalls = { kind: 'delete', callId: event.data.callId } - break - } - // Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary - // case above must sit inside an open turn. The durable session log uses the - // turn as its commit/replay boundary (the JSONL backend treats anything - // after the last turn/end as a crash tail), so a bare event between turns is - // silently dropped on reload. The loop records queued user messages after - // turn/start, and an idle agent.inject() wraps its context/message in a - // one-shot turn. A `default` - // (not an enumerated list) is deliberate: SessionEventMap is - // merge-extensible, so a PLUGIN-added event type appended while idle must - // also fail here rather than fall through and be dropped on resume. - default: { - if (trace.openTurn === null) { - throw new InvariantError(`${event.type} appended outside any open turn (every event must be turn-enclosed)`) - } - break - } - } - return { - scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep }, - pendingCalls, +declare module 'cordis' { + interface Context { + invariants: InvariantService } } -/** Apply one already-validated transition after its event commits. */ -function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void { - Object.assign(trace, transition.scalars) - switch (transition.pendingCalls.kind) { - case 'none': - break - case 'add': - trace.pendingCalls.add(transition.pendingCalls.callId) - break - case 'delete': - trace.pendingCalls.delete(transition.pendingCalls.callId) - break - case 'clear': - trace.pendingCalls.clear() - break - /* v8 ignore next -- validateEvent produces this closed transition union */ - default: - assertNever(transition.pendingCalls, 'session trace pending-call transition') - } +/** Compile and validate one package-filter list. */ +function compilePatterns(field: 'package_allowlist' | 'package_blocklist', values: readonly string[]): RegExp[] { + const seen = new Set() + return values.map((value) => { + if (value.length === 0 || value.trim() !== value) { + throw new Error(`invariants: ${field} entries must be non-blank and have no surrounding whitespace`) + } + if (seen.has(value)) { + throw new Error(`invariants: ${field} contains duplicate regex ${JSON.stringify(value)}`) + } + seen.add(value) + try { + return new RegExp(value) + } catch (cause) { + throw new Error(`invariants: ${field} contains invalid regex ${JSON.stringify(value)}`, { cause }) + } + }) } -/** Validate and apply one event while rebuilding an already-committed log. */ -function replayEvent(trace: SessionTrace, event: SessionEvent): void { - applyTransition(trace, validateEvent(trace, event)) -} - -/** Allow an initial observation, idle/running transitions, and terminal disposal; reject repeats and leaving disposed. */ -function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void { - if (from === undefined) return - if (from === to) { - throw new InvariantError(`agent/status repeated ${to} (no-op transition)`) - } - if (from === 'disposed') { - throw new InvariantError(`agent/status left terminal state disposed → ${to}`) - } -} - -/** - * Register the runtime invariants. Contributions are effect-scoped, so - * 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): void { - const traces = new WeakMap() - const stagedTransitions = 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. - const lastStatus = new WeakMap() - - const freshTrace = (): SessionTrace => ({ - lastSeq: -1, - openTurn: null, - openStep: null, - nextTurn: 1, - nextStep: 1, - pendingCalls: new Set(), +/** Package-owned invariant registry with global and regex-based selection. */ +export class InvariantService extends Service { + static Config: Schema = z.object({ + enabled: z.boolean().default(true), + package_allowlist: z.array(z.string()).default([]), + package_blocklist: z.array(z.string()).default([]), }) - /** 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) { - replayEvent(trace, event) - } - return trace + private readonly enabled: boolean + private readonly ownerCtx: Context + private readonly packageAllowlist: readonly RegExp[] + private readonly packageBlocklist: readonly RegExp[] + private readonly registrations = new Set() + + /** + * Create and install the invariant registry. + * @param ctx - Cordis context that owns the service. + * @param config - global enablement and package-name regex filters. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'invariants') + this.ownerCtx = ctx + this.enabled = config.enabled ?? true + this.packageAllowlist = compilePatterns('package_allowlist', config.package_allowlist ?? []) + this.packageBlocklist = compilePatterns('package_blocklist', config.package_blocklist ?? []) } - // Every store-created session (the only kind that emits session/event) is - // seeded first — via ctx.sessions.list() at apply or session/created — so the - // fallback is a defensive guard, never hit in practice. - /* v8 ignore next -- traceFor's fallback: session/event always follows a seed */ - const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session) + /** Return whether one full package name passes the configured filters. */ + private selected(packageName: string): boolean { + if (!this.enabled) return false + if (this.packageAllowlist.length > 0 + && !this.packageAllowlist.some(pattern => pattern.test(packageName))) return false + return !this.packageBlocklist.some(pattern => pattern.test(packageName)) + } - // Rebuild state for sessions that already exist at (re-)apply time — HMR - // reload starts a fresh fiber, and a mid-turn session would otherwise look - // like it began with a stray chunk/step-end. - for (const session of ctx.sessions.list()) seedSession(session) - - // A newly created session may arrive seeded/forked (the constructor copies - // the seed WITHOUT emitting session/event), so replay its log here too. - ctx.on('session/created', (session) => { seedSession(session) }, { global: true }) - - ctx.on('session/event', (session, event) => { - // Session resolves dispatch before committing, so internal/dispatch has - // already staged this exact event. A later dispatch veto skips every - // session/event callback and therefore leaves the live trace unchanged. - const staged = stagedTransitions.get(event) - /* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */ - if (staged === undefined || staged.session !== session) { - throw new InvariantError('session/event reached publication without matching pre-commit validation') + /** + * Register one package's invariant installer. The package name is reserved + * even when filtering disables its checks. Enabled installers run in a child + * fiber; failure disposes that fiber and releases the reservation. + * @param packageName - full npm package name that owns the contribution. + * @param installer - synchronous listener installer for the child context. + * @returns an effect-scoped disposer for the registration. + */ + register(packageName: string, installer: InvariantInstaller): () => void { + if (packageName.length === 0 || packageName.trim() !== packageName || /\s/.test(packageName)) { + throw new Error('invariants: packageName must be non-blank and contain no whitespace') } - stagedTransitions.delete(event) - applyTransition(staged.trace, staged.transition) - }, { global: true }) - - ctx.on('agent/status', (agent, status) => { - checkTransition(lastStatus.get(agent), status) - lastStatus.set(agent, status) - }, { global: true }) - - // --- Scoped-dispatch invariants (the agent-scoping seam) --------------- - // - // Every scope-filtered event family must dispatch with a scope carrier - // (scopeTarget) whose key IS the subject the event's arguments name — - // a dispatch without one silently reverts that event to global delivery - // (agent-scoped listeners over-hear foreign agents), and a mis-keyed one - // delivers to the wrong agent's listeners. `internal/dispatch` fires - // synchronously before listener delivery, so a violation throws at the - // dispatching call site. The generated table maps each family to the unique - // payload path whose Program type matches the real scopeTarget routing key; - // `null` means the key is external to the payload, so only carrier presence - // can be asserted. - ctx.on('internal/dispatch', (_mode, name, args, thisArg) => { - const subjectOf = scopedSubjectResolverFor(name) - if (subjectOf === undefined) return - if (!isScopeCarrier(thisArg)) { - throw new InvariantError( - `"${name}" is a scope-filtered event but was dispatched without a scope carrier — ` - + 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))') - } - if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) { - throw new InvariantError( - `"${name}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — ` - + 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))') - } - if (name === 'session/event') { - const [session, event] = args as [Session, SessionEvent] - const trace = traceFor(session) - const transition = validateEvent(trace, event) - // The exact event identity reaches the contained post-commit listener. - // A later internal/dispatch listener may still veto; because validation - // is pure, abandoning this weakly keyed transition does not advance the - // committed trace or retain the session. - stagedTransitions.set(event, { session, trace, transition }) - } - }, { global: true }) - - // Request-reconstruction cross-check (the reconstructability RFC): a - // loop-built request — frozen envelope + live sessionId is the marker; a - // hand-built one-shot (compaction summarize) is unfrozen and skipped — must - // be EXACTLY what the session log reconstructs: - // - // - messages: the folded header's session prefix (messagePrefix — the - // `agent/session-prefix` product, logged on the header because no - // session event carries it) followed by the - // derivation over the log prefix strictly before the in-flight step's - // `step/start` (the reconstruction boundary). The derivation is compared - // against a FRESH Session built over that prefix — the same projection - // code with zero shared state, so the live cache under test cannot vouch - // for itself. Boundary-correct by construction: content appended after - // the boundary (an `agent/request`-window inject) is legitimately absent - // from this request, and a current-surface comparison would false-fire. - // - header: every non-content field must equal the fold of the log's - // `request/header` events — the loop logs the header event BEFORE - // dispatch, so the fold already covers this request. - // - // Registered with `prepend: true` so a short-circuiting llm/stream listener - // (the replay adapter returns its chunks without calling next()) cannot - // silence the check by registering first. Prepend beats APPEND-registered - // listeners only — two prepended listeners have no defined mutual order - // (cordis unshift) — which is fine: correctness rests on the seq-bounded - // fold below, never on listener timing. - ctx.on('llm/stream', (options: GenerateOptions, next) => { - if (options.sessionId === undefined || !Object.isFrozen(options)) return next() - // GenerateOptions types sessionId as Branded<'SessionId'>, which IS - // SessionId (dsh-llm cannot import it without a cycle) — no cast needed. - const session = ctx.sessions.get(options.sessionId) - if (!session) return next() - if (!Object.isFrozen(options.messages)) { - throw new InvariantError('a loop-built request must carry a frozen messages array') + if (this.registrations.has(packageName)) { + throw new Error(`invariants: package "${packageName}" is already registered`) } - const events = session.events - // seq === index (checked above), so the last step/start's seq bounds the - // prefix directly. The in-flight step's step/start is necessarily the - // last one: the loop cannot open another step while this call streams. - let boundary = -1 - for (let i = events.length - 1; i >= 0; i -= 1) { - if (events[i]?.type === 'step/start') { - boundary = i - break - } - } - if (boundary === -1) { - throw new InvariantError('a loop-built request with no step/start in its session log') - } - const header = foldRequestHeader(events) - if (header === undefined) { - throw new InvariantError('a loop-built request with no request/header event in its session log') - } - const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary))) - // The reconstruction equation: the folded header's session prefix, then - // the boundary derivation — the loop - // logs the header event BEFORE dispatch, so the fold already covers this - // request's prefix. JSON equality is sound here: both sides are - // structuredClones produced by the same projection/build code path, so key - // insertion order matches when the values do. - const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()] - if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { - throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`) - } + // Service method tracing binds `this.ctx` to the caller. This explicit + // origin keeps registrations and their child fibers owned by the service; + // companion disposal is covered independently by the returned disposer. + const ctx = this.ownerCtx + const registrations = this.registrations + registrations.add(packageName) - const headerMatches = options.model === header.config.model - && options.system === header.system - && options.temperature === header.config.temperature - && options.maxTokens === header.config.maxTokens - && JSON.stringify(options.stop) === JSON.stringify(header.config.stop) - && JSON.stringify(options.tools ?? []) === JSON.stringify(header.tools ?? []) - if (!headerMatches) { - throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the folded request header`) + let registration: PendingInvariantRegistration + try { + registration = ctx.effect(async () => { + if (!this.selected(packageName)) { + return () => { + registrations.delete(packageName) + } + } + + const installInvariant = (childCtx: Context) => { + installer(childCtx, (message): never => { + throw new InvariantError(packageName, message) + }) + } + const child = ctx.plugin(installer.inject === undefined + ? installInvariant + : Object.assign(installInvariant, { inject: installer.inject })) + + try { + await child + } catch (error) { + try { + await child.dispose() + } finally { + registrations.delete(packageName) + } + throw error + } + + return async () => { + try { + await child.dispose() + } finally { + registrations.delete(packageName) + } + } + }, `invariants.register(${JSON.stringify(packageName)})`) + } catch (error) { + registrations.delete(packageName) + throw error } - return next() - }, { global: true, prepend: true }) + // Cordis attaches setup thenability and async teardown to this callable; + // the service seam intentionally exposes only the conventional disposer. + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- the extra runtime shape stays private. + return registration + } } + +export default InvariantService diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts deleted file mode 100644 index 36d1721945..0000000000 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Generated scoped-event routing-subject resolvers for dsh-invariants. - * Do not edit by hand; run `pnpm run gen-scoped-events`. - * - * @module @deepseek-ai/dsh-invariants/scoped-events.generated - */ - -import type { Events } from 'cordis' -import type { Scoped } from '@deepseek-ai/dsh-scope' -import type {} from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-subagent' -import type {} from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-user-approval' - -type ScopedEventName = { - [K in keyof Events]: ThisParameterType extends Scoped ? K : never -}[keyof Events] - -type ScopedSubjectResolver = (args: readonly unknown[]) => unknown - -function adapt( - resolver: (args: Parameters) => unknown, -): ScopedSubjectResolver { - return args => resolver(args as Parameters) -} - -const scopedSubjectResolvers = Object.freeze({ - 'agent/created': adapt<'agent/created'>(args => args[0]), - 'agent/disposed': adapt<'agent/disposed'>(args => args[0]), - 'agent/error': adapt<'agent/error'>(args => args[0]), - 'agent/post-step': adapt<'agent/post-step'>(args => args[0]), - 'agent/pre-step': adapt<'agent/pre-step'>(args => args[0]), - 'agent/prompt-submit': adapt<'agent/prompt-submit'>(args => args[0]), - 'agent/queued': adapt<'agent/queued'>(args => args[0]), - 'agent/request': adapt<'agent/request'>(args => args[0]), - 'agent/request-error': adapt<'agent/request-error'>(args => args[0]), - 'agent/session-prefix': adapt<'agent/session-prefix'>(args => args[0]), - 'agent/session-start': adapt<'agent/session-start'>(args => args[0]), - 'agent/status': adapt<'agent/status'>(args => args[0]), - 'agent/step-result': adapt<'agent/step-result'>(args => args[0]), - 'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]), - 'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]), - 'approval/request': adapt<'approval/request'>(args => args[0].agent), - 'session/created': null, - 'session/disposed': null, - 'session/event': null, - 'session/flush': null, - 'subagent/end': null, - 'subagent/start': null, - 'system-prompt/assemble': adapt<'system-prompt/assemble'>(args => args[1].scope), - 'tools/execute': adapt<'tools/execute'>(args => args[0].agent), - 'tools/post-execute': adapt<'tools/post-execute'>(args => args[0].agent), - 'tools/pre-execute': adapt<'tools/pre-execute'>(args => args[0].agent), - 'tools/result': adapt<'tools/result'>(args => args[0].agent), -} as const satisfies Readonly>) - -const scopedSubjectResolverIndex: Readonly> = scopedSubjectResolvers - -/** - * Resolve the routing key named by one scoped event payload. A null - * resolver means the payload cannot expose its external routing key, so the - * invariant checks carrier presence only. - * @param event - runtime Cordis event name. - * @returns the generated subject resolver, null for presence-only, - * or undefined when the event is not scope-filtered. - */ -export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined { - return scopedSubjectResolverIndex[event] -} diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts deleted file mode 100644 index de0046c00e..0000000000 --- a/packages/support/invariants/tests/invariants.spec.ts +++ /dev/null @@ -1,851 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' -import { CallId } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -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() { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(Invariants) - return { ctx, fiber } -} - -/** A minimal Agent stand-in for agent/status emission. */ -function mockAgent(id: string): Agent { - return { id } as unknown as Agent -} - -describe('session-log invariants', () => { - it('keeps pre-commit staging and post-commit application global when mounted under a scope', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - let scopedCtx!: Context - await ctx.plugin(Object.assign((inner: Context) => { - scopedCtx = createScope(inner, {}).ctx - }, { inject: ['sessions'] })) - await scopedCtx.plugin(Invariants) - const globalSession = ctx.sessions.create(SessionId('global-under-scoped-invariants')) - - expect(() => { - globalSession.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - globalSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }).not.toThrow() - }) - - it('accepts a well-formed turn/step/tool sequence', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }] }, { surfaceOp: 'append' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) - session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }).not.toThrow() - }) - - it('does not advance the trace when a later internal-dispatch listener vetoes', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create(SessionId('dispatch-veto-rollback')) - let veto = true - ctx.on('internal/dispatch', (_mode, name) => { - if (name !== 'session/event' || !veto) return - veto = false - throw new Error('later dispatch veto') - }) - - expect(() => session.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - })).toThrow('later dispatch veto') - expect(session.events).toEqual([]) - - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }).not.toThrow() - expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) - }) - - it('applies the committed transition after a prepended observer throws', async () => { - const { ctx } = await setup() - const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - const session = ctx.sessions.create(SessionId('postcommit-peer')) - ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true }) - - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }).not.toThrow() - expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) - expect(warnings).toEqual([ - 'session "postcommit-peer": session/event listener threw: Error: hostile observer', - 'session "postcommit-peer": session/event listener threw: Error: hostile observer', - ]) - }) - - it('rejects a non-monotonic seq (replay spine)', async () => { - 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. - 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) - expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } } } as never) }) - .toThrow(/seq must strictly increase/) - }) - - it('rejects a turn/start while another turn is open', async () => { - 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' } } })) - .toThrow(/turn 1 is still open/) - }) - - it('rejects a turn/end that does not match the open turn', async () => { - 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' } })) - .toThrow(/does not match open turn 1/) - }) - - it('rejects a step/start outside its declared turn', async () => { - 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() - 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 }) - expect(() => session.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/) - }) - - it('rejects an assistant/chunk outside an open step', async () => { - 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' } })) - .toThrow(/open is turn 1\/step null/) - }) - - it('rejects a message event appended outside any open turn (turn-enclosure)', async () => { - 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' })) - .toThrow(/outside any open turn/) - expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) - .toThrow(/outside any open turn/) - }) - - it('rejects steering and plugin-added events appended outside any open turn', async () => { - 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). - expect(() => session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) - .toThrow(/outside any open turn/) - // A PLUGIN-added (merge-extensible) event type is caught by the default too. - // Cast through `any`: 'compaction/marker' is not in SessionEventType (it's - // merge-extensible), so the typed append() won't accept it. The test verifies - // the runtime default-branch turn-enclosure check. - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return - expect(() => (session.append as any)('compaction/marker', { foo: 'bar' })) - .toThrow(/outside any open turn/) - }) - - it('accepts message events once a turn is open', async () => { - 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' })) - .not.toThrow() - }) - - it('rejects a tool/result with no prior tool/call', async () => { - 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 }) - expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false }, { surfaceOp: 'append' })) - .toThrow(/no prior tool\/call/) - }) - - it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [ - { type: 'tool-call', id: CallId('crashed'), name: 'bash', arguments: '{}' }, - ] }, { surfaceOp: 'append' }) - session.append('tool/result', { - turn: 1, - step: 1, - callId: CallId('crashed'), - content: [{ type: 'text', text: 'interrupted' }], - isError: true, - error: { name: 'InterruptedError', code: 'interrupted' }, - }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) - }).not.toThrow() - }) - - it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) - }).not.toThrow() - }) - - it('holds seeded sessions to the contract on session/created', async () => { - 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 - // plugin when it replays the seed on session/created. - const badSeed = [ - { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, - { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, - ] - expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError) - }) - - it('tracks turns per session independently', async () => { - 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' } } }) - // b is a fresh session — its own turn/start must not see a's open turn. - expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow() - }) - - it('accepts multiple steps in a turn and consecutive turns', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - expect(() => { - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('step/start', { turn: 1, step: 2 }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [] }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 2 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - }).not.toThrow() - }) - - it('rejects a skipped turn number', async () => { - 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' } }) - expect(() => session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })) - .toThrow(/expected turn 2, got 3/) - }) - - it('rejects a skipped step number within a turn', async () => { - 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 }) - session.append('step/end', { turn: 1, step: 1 }) - expect(() => session.append('step/start', { turn: 1, step: 3 })) - .toThrow(/expected step 2 in turn 1, got 3/) - }) - - it('rejects a turn/end while a step is still open', async () => { - 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 }) - expect(() => session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })) - .toThrow(/while step 1 is still open/) - }) - - it('rejects a step/start while a step is still open', async () => { - 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 }) - expect(() => session.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/) - }) - - it('rejects a tool/result satisfying a call from a previous step', async () => { - 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 }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) - // step ends with the call unresolved — pendingCalls is cleared. - session.append('step/end', { turn: 1, step: 1 }) - session.append('step/start', { turn: 1, step: 2 }) - expect(() => session.append('tool/result', { turn: 1, step: 2, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })) - .toThrow(/no prior tool\/call in this step/) - }) - - it('rejects an assistant/message naming the wrong step', async () => { - 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 }) - expect(() => session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [] }, { surfaceOp: 'append' })) - .toThrow(/open is turn 1\/step 1/) - }) -}) - -describe('HMR state rebuild', () => { - it('rebuilds trace state for a session that exists at (re-)apply time', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - 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 }) - await first.dispose() - - // Re-apply mid-step: the new fiber must reconstruct the open boundaries from the log. - await ctx.plugin(Invariants) - expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })) - .not.toThrow() - // Rebuild must not disable later violations. - expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) - .toThrow(/turn 1 is still open/) - }) -}) - -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('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 = 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('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() - }) -}) - -describe('agent status invariants', () => { - it('accepts legal transitions: idle→running→idle and →disposed', async () => { - const { ctx } = await setup() - const agent = mockAgent('a1') - expect(() => { - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') - }).not.toThrow() - }) - - it('accepts running→disposed', async () => { - 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() - 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() - 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() - const a = mockAgent('a5') - const b = mockAgent('b5') - ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') - // b's first observation is independent of a. - expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() - }) -}) - -describe('HMR safety', () => { - it('removes all listeners when the plugin fiber is disposed', async () => { - const { ctx, fiber } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - - await fiber.dispose() - - // 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(true) - // A no-op status transition no longer throws either. - const agent = mockAgent('hmr') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).not.toThrow() - }) - - it('InvariantError carries a stable code', () => { - const err = new InvariantError('seq must strictly increase') - expect(err).toBeInstanceOf(Error) - expect(err.name).toBe('InvariantError') - expect(err.code).toBe('INVARIANT') - expect(err.message).toBe('invariant violated: seq must strictly increase') - }) - - 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' }) - // 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(true) - }) -}) - -describe('surface contract under the invariants composition', () => { - it('accepts well-formed surface metadata', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - // Events must be turn-enclosed and step-scoped events need an open step. - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - expect(() => { - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] }) - }).not.toThrow() - }) - - it('accepts replace surface op', async () => { - 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 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) - // no throw — well-formed replace op - }) - - it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => { - 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 }) - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] }) - }).not.toThrow() - expect(() => { - session.append('user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append', sourceEventSeqs: [] }) - }).toThrow(/must not be empty except on assistant\/message/) - }) - - it('rejects duplicate sourceEventSeqs', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1, 1] }) - }).toThrow(/must not contain duplicates/) - }) - - it('rejects sourceEventSeqs referencing the event itself (self-reference)', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) // seq 0 - // The next event is seq 1. Referencing its own seq fails on "must reference - // earlier events" (the check order is: earlier first, then unknown). - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] }) - }).toThrow(/must reference earlier/) - }) - - it('accepts sourceEventSeqs referencing a valid earlier event', async () => { - // Session seqs are contiguous, so every non-negative ref below the current - // seq necessarily names an existing earlier event. - 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 }) - // seqs so far: 0, 1. The next event at seq 2 references seq 1 → valid. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [1] }) - }).not.toThrow() - }) - - it('rejects sourceEventSeqs referencing a far-future seq', async () => { - 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/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [99] }) - }).toThrow(/must reference earlier/) - }) - - it('rejects a replace whose start is positioned after its end on the surface', async () => { - 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 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - // Reversed range: start seq 3 is at a later surface position than end seq 2. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] }) - }).toThrow(/is after end seq 2/) - }) - - it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => { - 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 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - // Replace shadows surface nodes [2, 3] but records provenance for only [2]. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2] }) - }).toThrow(/must include every shadowed surface node; missing 3/) - }) - - it('accepts a replace whose sourceEventSeqs covers every shadowed surface node', async () => { - 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 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2, 3] }) - }).not.toThrow() - }) - - it('rejects a replace naming a start seq that is not on the surface', async () => { - 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 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - // seq 1 (step/start) is a real earlier event but never entered the surface. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }) - }).toThrow(/start seq 1 not found in surface/) - }) - - it('rejects a replace naming an end seq that is not on the surface', async () => { - 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 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - // start (2) is on the surface but end (99) never entered it. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] }) - }).toThrow(/end seq 99 not found in surface/) - }) - - it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', async () => { - 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 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - // Replace node 2 (position 0) with seq 4 — surface is now [4, 3], so seq 4 - // precedes seq 3 in surface order even though 4 > 3 numerically. - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4 - // A replace with start=3, end=4 passes the seq check (3 <= 4) but is - // reversed positionally (3 is at pos 1, 4 is at pos 0). - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5 - }).toThrow(/is after end seq 4/) - }) - - it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => { - 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 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3 - // Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the - // head seq (4) is numerically GREATER than the tail seq (3): the surface is - // not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is - // valid positionally and must be accepted even though start seq > end seq. - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4 - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5 - }).not.toThrow() - }) - - it('rejects a replace that omits sourceEventSeqs entirely', async () => { - 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 }) - session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2 - // A replace with no sourceEventSeqs records no provenance for the node it shadows. - expect(() => { - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }) - }).toThrow(/must include every shadowed surface node; missing 2/) - }) - - it('catches an incomplete-provenance replace on the load/seed path', async () => { - 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 } }, - { type: 'user/message' as const, seq: 2, time: 0, data: { content: [{ type: 'text' as const, text: 'a' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const }, - { type: 'user/message' as const, seq: 3, time: 0, data: { content: [{ type: 'text' as const, text: 'b' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const }, - { type: 'assistant/message' as const, seq: 4, time: 0, data: { turn: 1, step: 1, content: [{ type: 'text' as const, text: 'sum' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: { op: 'replace' as const, start: 2, end: 3 }, sourceEventSeqs: [2] }, - ] - expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/) - }) - -}) - -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() - 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' }) - const boundary = session.deriveMessages() - session.append('step/start', { turn: 1, step: 1 }) - session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) - return { ctx, session, boundary } - } - - /** Dispatch the llm/stream waterfall with a stub core, collecting the check's verdict. */ - function dispatch(ctx: Context, options: unknown): void { - // The invariants listener runs synchronously at dispatch time (its checks - // precede next()); the stub core just yields nothing. - void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never) - } - - it('passes a frozen request that equals the boundary derivation + the folded header', async () => { - const { ctx, session, boundary } = await requestSetup() - const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).not.toThrow() - }) - - it('is boundary-correct: content logged after step/start is legitimately absent from this request', async () => { - const { ctx, session, boundary } = await requestSetup() - // An agent/request-window inject: lands in the log after the boundary, - // belongs to the NEXT request. A current-surface comparison would - // false-fire here; the seq-bounded rebuild must not. - session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) - const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).not.toThrow() - }) - - it('expects the folded header\'s session prefix ahead of the derivation (prefix + derived)', async () => { - const { ctx, session, boundary } = await requestSetup() - const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } - session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' }) - // The prefixed request matches the fold… - const prefixed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id }) - expect(() => { dispatch(ctx, prefixed) }).not.toThrow() - // …a request that DROPPED the logged prefix diverges… - const bare = Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id }) - expect(() => { dispatch(ctx, bare) }).toThrow(/diverges from the boundary derivation/) - // …and so does one that misplaced it (prefix sent after the history). - const misplaced = Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id }) - expect(() => { dispatch(ctx, misplaced) }).toThrow(/diverges from the boundary derivation/) - }) - - it('rejects a frozen request whose messages diverge from the boundary derivation', async () => { - const { ctx, session, boundary } = await requestSetup() - const messages = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }] - const options = Object.freeze({ model: 'm', messages: Object.freeze(messages), sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).toThrow(/diverges from the boundary derivation/) - }) - - it('rejects a frozen request whose fields diverge from the folded header', async () => { - const { ctx, session, boundary } = await requestSetup() - const options = Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).toThrow(/diverges from the folded request header/) - }) - - it('rejects a loop-built request with no header event or no step/start in its log', async () => { - 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 }) - expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/) - - session.append('step/start', { turn: 1, step: 1 }) - expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/) - }) - - it('rejects a frozen request carrying an unfrozen messages array', async () => { - const { ctx, session, boundary } = await requestSetup() - const options = Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id }) - expect(() => { dispatch(ctx, options) }).toThrow(/frozen messages array/) - }) - - it('skips hand-built (unfrozen) requests — compaction summarize is out of scope', async () => { - const { ctx, session } = await requestSetup() - // Unfrozen envelope + arbitrary messages: a direct one-shot call. - const options = { model: 'summarizer', messages: [{ role: 'user', content: [{ type: 'text', text: 'summarize!' }] }], sessionId: session.id } - expect(() => { dispatch(ctx, options) }).not.toThrow() - }) - - it('skips requests without a sessionId or with an unknown session', async () => { - const { ctx } = await requestSetup() - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow() - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) }).not.toThrow() - }) -}) - -describe('request cross-check ordering (prepend)', () => { - it('runs ahead of a short-circuiting llm/stream listener registered before it', async () => { - // Replay short-circuits without next(), so the check prepends ahead of ordinary listeners; - // correctness still comes from its sequence-bounded rebuild, not listener timing. - const ctx = new Context() - await ctx.plugin(SessionStore) - ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next() - await ctx.plugin(Invariants) - - const session = ctx.sessions.create(SessionId('prepend-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' }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) - - const divergent = Object.freeze({ - model: 'm', - messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]), - sessionId: session.id, - }) - expect(() => { - void ctx.waterfall('llm/stream', divergent as never, () => (async function* () {})() as never) - }).toThrow(/diverges from the boundary derivation/) - }) -}) - -describe('scoped-dispatch invariants', () => { - async function scopedCtx() { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(Invariants) - return ctx - } - - it('rejects a scoped-family dispatch without a carrier (teaching error)', async () => { - const ctx = await scopedCtx() - const agent = { id: 'a1' } as unknown as Agent - expect(() => { ctx.emit('agent/error', agent, 1, 0, new Error('x')) }) - .toThrow(/dispatched without a scope carrier/) - }) - - it('accepts a matching carrier and rejects a mismatched one for EVERY agent-subject event', async () => { - const ctx = await scopedCtx() - // Real Session objects keep the synthetic Agent handles structurally valid. - const agent = { id: 'a1', session: new Session(SessionId('a1-s')) } as unknown as Agent - const other = { id: 'a2', session: new Session(SessionId('a2-s')) } as unknown as Agent - // One dispatch per table row keeps every subject extractor covered: the - // matching carrier passes, the foreign-keyed one throws. - const rows: [string, unknown[]][] = [ - ['agent/created', [agent]], - ['agent/disposed', [agent]], - ['agent/status', [agent, 'idle']], - ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], - ['agent/session-start', [agent, 'startup']], - ['agent/pre-step', [agent, 1, 1, new AbortController().signal]], - ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], - ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], - ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]], - ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]], - ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]], - ['agent/turn-stop', [agent, 1]], - ['agent/error', [agent, 1, 0, new Error('x')]], - ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], - ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], - ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], - ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], - ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]], - ] - for (const [event, args] of rows) { - const subject = agent - expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, subject), event, ...args) }, - `${event} with matching carrier`).not.toThrow() - expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) }, - `${event} with foreign carrier`).toThrow(/DIFFERENT subject/) - } - }) - - it('rejects a carrier keyed to a different subject than the arguments name', async () => { - const ctx = await scopedCtx() - const agent = { id: 'a1' } as unknown as Agent - const other = { id: 'a2' } as unknown as Agent - expect(() => { ctx.emit(scopeTarget(agent, other), 'agent/error', agent, 1, 0, new Error('x')) }) - .toThrow(/keyed to a DIFFERENT subject/) - // The correct spelling passes. - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/error', agent, 1, 0, new Error('x')) }) - .not.toThrow() - }) - -}) diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts new file mode 100644 index 0000000000..afeae22fb7 --- /dev/null +++ b/packages/support/invariants/tests/service.spec.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context, Service } from 'cordis' +import InvariantService, { InvariantError, type Config } from '@deepseek-ai/dsh-invariants' + +declare module 'cordis' { + interface Context { + invariantProbe: InvariantProbeService + } + + interface Events { + 'invariants-test/ping'(): void + } +} + +class InvariantProbeService extends Service { + constructor(ctx: Context) { + super(ctx, 'invariantProbe') + } +} + +interface RuntimeRegistration extends PromiseLike<() => void> { + (): void | Promise +} + +interface InstalledRegistration { + dispose(): Promise +} + +function runtimeRegistration(registration: () => void): RuntimeRegistration { + return registration as RuntimeRegistration +} + +async function setup(config: Config = {}): Promise<{ ctx: Context; fiber: Awaited> }> { + const ctx = new Context() + const fiber = await ctx.plugin(InvariantService, config) + return { ctx, fiber } +} + +async function registerProbe( + ctx: Context, + packageName: string, + probe: () => void, +): Promise { + const registration = runtimeRegistration(ctx.invariants.register(packageName, (child) => { + child.on('invariants-test/ping', probe, { global: true }) + })) + await registration + return { + async dispose() { await registration() }, + } +} + +describe('InvariantService selection', () => { + it('applies defaults when constructed directly without schema normalization', async () => { + const ctx = new Context() + const service = new InvariantService(ctx) + const probe = vi.fn() + const registration = runtimeRegistration(service.register('@deepseek-ai/dsh-session', (child) => { + child.on('invariants-test/ping', probe, { global: true }) + })) + await registration + ctx.emit('invariants-test/ping') + expect(probe).toHaveBeenCalledOnce() + await registration() + }) + + it('enables registrations by default and treats empty lists as admit-all and exclude-none', async () => { + for (const config of [{}, { package_allowlist: [], package_blocklist: [] }]) { + const { ctx } = await setup(config) + const probe = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', probe) + ctx.emit('invariants-test/ping') + expect(probe).toHaveBeenCalledOnce() + } + }) + + it('disables every installer while still reserving package ownership', async () => { + const { ctx } = await setup({ enabled: false }) + const probe = vi.fn() + const registration = await registerProbe(ctx, '@deepseek-ai/dsh-session', probe) + expect(() => ctx.invariants.register('@deepseek-ai/dsh-session', () => {})) + .toThrow(/already registered/) + ctx.emit('invariants-test/ping') + expect(probe).not.toHaveBeenCalled() + await registration.dispose() + }) + + it('uses unanchored, case-sensitive JavaScript regex sources', async () => { + const unanchored = await setup({ package_allowlist: ['session'] }) + const unanchoredProbe = vi.fn() + await registerProbe(unanchored.ctx, '@deepseek-ai/dsh-session-extra', unanchoredProbe) + unanchored.ctx.emit('invariants-test/ping') + expect(unanchoredProbe).toHaveBeenCalledOnce() + + const anchored = await setup({ package_allowlist: ['^@deepseek-ai/dsh-session$'] }) + const anchoredProbe = vi.fn() + await registerProbe(anchored.ctx, '@deepseek-ai/dsh-session-extra', anchoredProbe) + anchored.ctx.emit('invariants-test/ping') + expect(anchoredProbe).not.toHaveBeenCalled() + + const caseSensitive = await setup({ package_allowlist: ['Session'] }) + const caseProbe = vi.fn() + await registerProbe(caseSensitive.ctx, '@deepseek-ai/dsh-session', caseProbe) + caseSensitive.ctx.emit('invariants-test/ping') + expect(caseProbe).not.toHaveBeenCalled() + }) + + it('lets the blocklist override an allowlist match', async () => { + const { ctx } = await setup({ + package_allowlist: ['^@deepseek-ai/dsh-'], + package_blocklist: ['session'], + }) + const sessionProbe = vi.fn() + const agentProbe = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', sessionProbe) + await registerProbe(ctx, '@deepseek-ai/dsh-agent', agentProbe) + ctx.emit('invariants-test/ping') + expect(sessionProbe).not.toHaveBeenCalled() + expect(agentProbe).toHaveBeenCalledOnce() + }) + + it('accepts zero-match patterns for packages registered later', async () => { + const { ctx } = await setup({ package_allowlist: ['^@later/invariants$'] }) + const now = vi.fn() + const later = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', now) + await registerProbe(ctx, '@later/invariants', later) + ctx.emit('invariants-test/ping') + expect(now).not.toHaveBeenCalled() + expect(later).toHaveBeenCalledOnce() + }) + + it('allows the same source in both lists and applies blocklist precedence', async () => { + const { ctx } = await setup({ package_allowlist: ['agent'], package_blocklist: ['agent'] }) + const probe = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-agent', probe) + ctx.emit('invariants-test/ping') + expect(probe).not.toHaveBeenCalled() + }) +}) + +describe('InvariantService validation', () => { + it.each([ + [{ package_allowlist: [''] }, /non-blank/], + [{ package_allowlist: [' '] }, /non-blank/], + [{ package_allowlist: [' session'] }, /surrounding whitespace/], + [{ package_blocklist: ['session '] }, /surrounding whitespace/], + [{ package_allowlist: ['session', 'session'] }, /duplicate regex/], + [{ package_blocklist: ['agent', 'agent'] }, /duplicate regex/], + [{ package_allowlist: ['['] }, /invalid regex/], + [{ package_blocklist: ['('] }, /invalid regex/], + ])('rejects malformed filter config %#', async (config, message) => { + await expect((async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, config) + })()).rejects.toThrow(message) + }) + + it.each(['', ' ', ' package', 'pack age', 'package\n'])('rejects malformed package name %j', async (packageName) => { + const { ctx } = await setup() + expect(() => ctx.invariants.register(packageName, () => {})).toThrow(/packageName/) + }) +}) + +describe('InvariantService lifecycle', () => { + it('honors the installer dependency surface in its child fiber', async () => { + const { ctx } = await setup() + await ctx.plugin(InvariantProbeService) + let registration!: RuntimeRegistration + await ctx.plugin({ + inject: ['invariants', 'invariantProbe'], + apply(child: Context) { + const installer = Object.assign((installerCtx: Context) => { + expect(Object.keys(installerCtx.fiber.inject)).toContain('invariantProbe') + expect(Object.keys(installerCtx.fiber.store ?? {})).toContain('invariantProbe') + expect(installerCtx.invariantProbe).toBeInstanceOf(InvariantProbeService) + }, { inject: ['invariantProbe'] }) + expect(installer.inject).toEqual(['invariantProbe']) + registration = runtimeRegistration(child.invariants.register('@deepseek-ai/dsh-probe', installer)) + return Promise.resolve(registration) + }, + }) + await registration + }) + + it('attributes failures to the registering package with the stable code', async () => { + const { ctx } = await setup() + const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', (child, fail) => { + child.on('invariants-test/ping', () => fail('seq must strictly increase'), { global: true }) + })) + await registration + let caught: unknown + try { + ctx.emit('invariants-test/ping') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(InvariantError) + expect(caught).toMatchObject({ + name: 'InvariantError', + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-session', + message: 'invariant violated by "@deepseek-ai/dsh-session": seq must strictly increase', + }) + }) + + it('disposes the child fiber completely and permits HMR re-registration', async () => { + const { ctx } = await setup() + const first = vi.fn() + const firstRegistration = await registerProbe(ctx, '@deepseek-ai/dsh-session', first) + ctx.emit('invariants-test/ping') + await firstRegistration.dispose() + ctx.emit('invariants-test/ping') + expect(first).toHaveBeenCalledOnce() + + const second = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', second) + ctx.emit('invariants-test/ping') + expect(first).toHaveBeenCalledOnce() + expect(second).toHaveBeenCalledOnce() + }) + + it('reserves ownership until asynchronous child disposal completes', async () => { + const { ctx } = await setup() + let finishDisposal!: () => void + const disposalBarrier = new Promise((resolve) => { finishDisposal = resolve }) + const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', (child) => { + child.effect(() => async () => { await disposalBarrier }) + })) + await registration + + const disposing = registration() + expect(() => ctx.invariants.register('@deepseek-ai/dsh-session', () => {})) + .toThrow(/already registered/) + finishDisposal() + await disposing + + const replacement = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', () => {})) + await replacement + await replacement() + }) + + it('rolls back listeners and ownership atomically when an installer fails', async () => { + const { ctx } = await setup() + const leaked = vi.fn() + const failed = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-session', (child) => { + child.on('invariants-test/ping', leaked, { global: true }) + throw new Error('installer failed') + })) + await expect(Promise.resolve(failed)).rejects.toThrow('installer failed') + ctx.emit('invariants-test/ping') + expect(leaked).not.toHaveBeenCalled() + + const retry = vi.fn() + await registerProbe(ctx, '@deepseek-ai/dsh-session', retry) + ctx.emit('invariants-test/ping') + expect(retry).toHaveBeenCalledOnce() + }) + + it('releases a synchronous reservation if the service fiber is already inactive', async () => { + const { ctx, fiber } = await setup() + const service = ctx.invariants + await fiber.dispose() + expect(() => service.register('@deepseek-ai/dsh-session', () => {})).toThrow(/inactive/i) + }) +}) diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json index 6c5bc479b5..f0063ae28d 100644 --- a/packages/support/invariants/tsconfig.json +++ b/packages/support/invariants/tsconfig.json @@ -15,28 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/scope" - }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../ui/user-approval" - }, - { - "path": "../../core/tools" - }, - { - "path": "../../subagent/subagent" + "path": "../../../vendor/schemastery" } ] } diff --git a/packages/ui/acp/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index 3611c55e1f..0219940616 100644 --- a/packages/ui/acp/tests/config-options.spec.ts +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -8,7 +8,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import ApprovalService from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' @@ -25,6 +28,13 @@ class SandboxedLocalExecutor extends LocalBashExecutor { } } +async function mountInvariants(ctx: BridgeHarness['ctx']): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + function permissionOption(currentValue: string): object { return { id: 'permission', @@ -76,7 +86,7 @@ describe('acp bridge — session config options', () => { async function presetStack(options: { script?: NonNullable[0]>['script'] } = {}): Promise { const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} }) // Make an out-of-turn switch fail in this suite. - await harness.ctx.plugin(Invariants) + await mountInvariants(harness.ctx) await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 }) await harness.ctx.plugin(ApprovalService) await harness.ctx.plugin(PermissionService) diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index 320a5322c7..e137d7f94f 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -3,7 +3,10 @@ import { Context } from 'cordis' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService from '@deepseek-ai/dsh-subagent' import * as spawn from '@deepseek-ai/dsh-subagent-spawn' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' @@ -12,6 +15,13 @@ import WorkerWorkflowEngine from '../src/index.ts' type Script = ConstructorParameters[0] +async function mountInvariants(ctx: Context): Promise { + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoopInvariant) +} + /** * The whole in-process stack, keyless, with the script in a REAL worker * thread: the engine drives the REAL spawn backend (with its @@ -24,7 +34,7 @@ async function setup(script: Script) { const ctx = new Context() const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7649345fc..43aae285c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,6 +517,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -572,6 +575,9 @@ importers: packages/core/scope: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -581,6 +587,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -707,6 +716,9 @@ importers: '@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 @@ -1745,31 +1757,11 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/support/invariants: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@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-subagent': - specifier: workspace:^ - version: link:../../subagent/subagent - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@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.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index c10dd1396c..224f8540b8 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -116,6 +116,14 @@ const dshWorkerPackageFiles = [ 'src', ] as const +const dshInvariantPackageFiles = [ + 'lib/index.js', + 'lib/invariant.js', + 'lib/types/**/*.d.ts', + 'lib/types/**/*.d.ts.map', + 'src', +] as const + const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-scripts': [ @@ -142,6 +150,9 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { ] } if (manifest.bin) return dshBinPackageFiles + // Package-owned diagnostic companions are separately bundled optional + // entries; their source stays in the owner package without bloating root. + if (manifest.exports?.['./invariant']) return dshInvariantPackageFiles // A declared "./worker" subpath export sanctions the one extra runtime // bundle a worker-thread entry needs (and NodeNext/publint then validate // that subpath's targets like any other export). @@ -188,6 +199,16 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.exports?.['.']?.default !== './lib/index.js') { errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`) } + const invariantExport = manifest.exports?.['./invariant'] + if (invariantExport?.types !== undefined && invariantExport.types !== './lib/types/invariant.d.ts') { + errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`) + } + if (invariantExport?.default !== undefined && invariantExport.default !== './lib/invariant.js') { + errors.push(`${label}: package.json exports["./invariant"].default must be "./lib/invariant.js"`) + } + if (invariantExport && (invariantExport.types === undefined || invariantExport.default === undefined)) { + errors.push(`${label}: package.json exports["./invariant"] must declare both types and default targets`) + } const expectedFiles = expectedDshPackageFiles(manifest) if (!sameStringList(manifest.files, expectedFiles)) { errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 15780b17e0..f0d1a412dc 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -155,6 +155,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', + InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', + InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0681153330..dbdc3102e3 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -100,9 +100,17 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, + { + key: 'invariants', + pkg: 'invariants', + title: 'Package-owned invariant registry', + mode: 'core', + consumers: ['session', 'agent', 'scope', 'agent-loop'], + note: 'Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures.', + }, { key: 'sessionPersistence', pkg: 'session-persistence', @@ -158,7 +166,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent', title: 'Agent service', mode: 'core', - consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo', 'invariants'], + consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo'], note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.', }, { diff --git a/scripts/gen-scoped-events.ts b/scripts/gen-scoped-events.ts index a7b93493ca..4aa6637934 100644 --- a/scripts/gen-scoped-events.ts +++ b/scripts/gen-scoped-events.ts @@ -1,6 +1,6 @@ /** - * Generate the dev-invariants scoped-event resolver map from the - * repository TypeScript Program. + * Generate dsh-scope's invariant resolver map from the repository TypeScript + * Program. * * A scoped event declares `this: Scoped`. Real `scopeTarget(base, key)` * calls establish the routing-key type for that base. The generator searches @@ -20,7 +20,7 @@ import { pointer, rawJsDoc } from './jsdoc.ts' import { TypeScriptProject } from './ts-project.ts' const root = resolve(import.meta.dirname, '..') -const OUT = 'packages/support/invariants/src/scoped-events.generated.ts' +const OUT = 'packages/core/scope/src/scoped-events.generated.ts' const SCOPE_DOC_MARKER = 'Scope-filtered dispatch' interface ScopeTargetContract { @@ -39,7 +39,6 @@ interface SubjectCandidate { interface ScopedEventResolver { event: string candidate: SubjectCandidate | null - ownerPackage: string } interface ScopeTag { @@ -54,7 +53,6 @@ class ScopedEventGenerator { private readonly scopeTargetDeclaration: ts.FunctionDeclaration private readonly scopedSymbol: ts.Symbol private readonly violations: string[] = [] - private readonly packageNames = new Map() constructor(private readonly project: TypeScriptProject) { this.checker = project.checker @@ -81,44 +79,25 @@ class ScopedEventGenerator { + this.violations.map(violation => ` - ${violation}`).join('\n'), ) } - const ownerImports = [...new Set(resolvers.map(resolver => resolver.ownerPackage))] - .sort() - .map(packageName => `import type {} from ${quote(packageName)}`) return [ '/**', - ' * Generated scoped-event routing-subject resolvers for dsh-invariants.', + ' * Generated scoped-event routing-subject resolvers for dsh-scope invariants.', ' * Do not edit by hand; run `pnpm run gen-scoped-events`.', ' *', - ' * @module @deepseek-ai/dsh-invariants/scoped-events.generated', + ' * @module @deepseek-ai/dsh-scope/scoped-events.generated', ' */', '', - "import type { Events } from 'cordis'", - "import type { Scoped } from '@deepseek-ai/dsh-scope'", - ...ownerImports, - '', - 'type ScopedEventName = {', - ' [K in keyof Events]: ThisParameterType extends Scoped ? K : never', - '}[keyof Events]', - '', 'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown', '', - 'function adapt(', - ' resolver: (args: Parameters) => unknown,', - '): ScopedSubjectResolver {', - ' return args => resolver(args as Parameters)', - '}', - '', - 'const scopedSubjectResolvers = Object.freeze({', + 'const scopedSubjectResolvers: Readonly> = Object.freeze({', ...resolvers.map(({ event, candidate }) => { if (candidate === null) return ` '${event}': null,` const subject = candidate.property === undefined ? `args[${candidate.parameter}]` - : `args[${candidate.parameter}].${candidate.property}` - return ` '${event}': adapt<'${event}'>(args => ${subject}),` + : `(args[${candidate.parameter}] as Record)[${quote(candidate.property)}]` + return ` '${event}': args => ${subject},` }), - '} as const satisfies Readonly>)', - '', - 'const scopedSubjectResolverIndex: Readonly> = scopedSubjectResolvers', + '})', '', '/**', ' * Resolve the routing key named by one scoped event payload. A null', @@ -129,7 +108,7 @@ class ScopedEventGenerator { ' * or undefined when the event is not scope-filtered.', ' */', 'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {', - ' return scopedSubjectResolverIndex[event]', + ' return scopedSubjectResolvers[event]', '}', '', ].join('\n') @@ -186,7 +165,6 @@ class ScopedEventGenerator { const resolvers: ScopedEventResolver[] = [] for (const sourceFile of this.packageSources) { const rel = this.project.relativePath(sourceFile) - const ownerPackage = this.packageName(packageRootFor(rel)) const visit = (node: ts.Node): void => { if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) { for (const member of node.members) { @@ -232,7 +210,7 @@ class ScopedEventGenerator { + 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload', ) } - resolvers.push({ event, candidate: null, ownerPackage }) + resolvers.push({ event, candidate: null }) continue } if (tag.unsupported) { @@ -241,7 +219,7 @@ class ScopedEventGenerator { ) continue } - resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage }) + resolvers.push({ event, candidate: candidates[0] ?? null }) } } ts.forEachChild(node, visit) @@ -311,19 +289,6 @@ class ScopedEventGenerator { return dedupeCandidates(candidates) } - /** Read and cache one workspace package name. */ - private packageName(packageRoot: string): string { - const cached = this.packageNames.get(packageRoot) - if (cached) return cached - const manifest: unknown = JSON.parse(readFileSync(resolve(root, packageRoot, 'package.json'), 'utf8')) - const name: unknown = typeof manifest === 'object' && manifest !== null - ? Reflect.get(manifest, 'name') - : undefined - if (typeof name !== 'string') throw new Error(`gen-scoped-events: ${packageRoot}/package.json has no name`) - this.packageNames.set(packageRoot, name) - return name - } - /** Compare exact Program type identities after removing null and undefined. */ private typesEquivalent(left: ts.Type, right: ts.Type): boolean { const normalizedLeft = this.normalizedType(left) @@ -398,13 +363,6 @@ function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandi }) } -/** Return the workspace package root owning one package source file. */ -function packageRootFor(relativePath: string): string { - const match = /^(packages\/[^/]+\/[^/]+)\/src\//.exec(relativePath) - if (!match?.[1]) throw new Error(`gen-scoped-events: cannot derive package root from ${relativePath}`) - return match[1] -} - /** Quote a generated property key as a single-quoted TypeScript string. */ function quote(value: string): string { return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'` @@ -419,7 +377,7 @@ export function renderScopedEvents(projectRoot: string = root): string { return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render() } -/** Generate or freshness-check the fixed invariants source file. */ +/** Generate or freshness-check the fixed dsh-scope source file. */ function main(): void { const content = renderScopedEvents() const output = resolve(root, OUT) diff --git a/tsconfig.base.json b/tsconfig.base.json index 53f69b2cf5..17cc6e472d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,6 +34,11 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], + "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], + "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], + "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], + "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json index 3a2b11db3c..d1d8f79f50 100644 --- a/website/.vitepress/config/api-sidebar.json +++ b/website/.vitepress/config/api-sidebar.json @@ -54,6 +54,10 @@ "text": "ctx.fs", "link": "/zh-CN/api/harness/fs" }, + { + "text": "ctx.invariants", + "link": "/zh-CN/api/harness/invariants" + }, { "text": "ctx.llm", "link": "/zh-CN/api/harness/llm" diff --git a/website/zh-CN/api/harness/invariants.md b/website/zh-CN/api/harness/invariants.md new file mode 100644 index 0000000000..b87ea93aa7 --- /dev/null +++ b/website/zh-CN/api/harness/invariants.md @@ -0,0 +1,32 @@ + + +# ctx.invariants + +`InvariantService` — provided by `@deepseek-ai/dsh-invariants`. + +Package-owned invariant registry with global and regex-based selection. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L94) + +### ctx.invariants.register(packageName, installer) + +```ts website-api +/** + * Register one package's invariant installer. The package name is reserved + * even when filtering disables its checks. Enabled installers run in a child + * fiber; failure disposes that fiber and releases the reservation. + * @param packageName - full npm package name that owns the contribution. + * @param installer - synchronous listener installer for the child context. + * @returns an effect-scoped disposer for the registration. + */ +register(packageName: string, installer: InvariantInstaller): () => void +``` + +Register one package's invariant installer. The package name is reserved even when filtering disables its checks. Enabled installers run in a child fiber; failure disposes that fiber and releases the reservation. + +- `packageName` — full npm package name that owns the contribution. +- `installer` — synchronous listener installer for the child context. + +**Returns** an effect-scoped disposer for the registration. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L136) From 2c7822ddee5fc0ac6f7253de07f8e88a5a43a206 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:26:38 +0800 Subject: [PATCH 02/30] fix(docs): map exact source aliases in built checks --- scripts/doc-typecheck-paths.spec.ts | 17 +++++++++++++++++ scripts/doc-typecheck-paths.ts | 11 +++++++++++ scripts/doc-typecheck.ts | 8 ++------ 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 scripts/doc-typecheck-paths.spec.ts create mode 100644 scripts/doc-typecheck-paths.ts diff --git a/scripts/doc-typecheck-paths.spec.ts b/scripts/doc-typecheck-paths.spec.ts new file mode 100644 index 0000000000..b0b3e3cfbf --- /dev/null +++ b/scripts/doc-typecheck-paths.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { builtDeclarationPath } from './doc-typecheck-paths.ts' + +describe('builtDeclarationPath', () => { + it('maps package source directories and exact entry files to built declarations', () => { + expect(builtDeclarationPath('./packages/*/*/src')).toBe('./packages/*/*/lib/types') + expect(builtDeclarationPath('./packages/support/invariants/src/index.ts')) + .toBe('./packages/support/invariants/lib/types/index.d.ts') + expect(builtDeclarationPath('./packages/core/session/src/invariant.ts')) + .toBe('./packages/core/session/lib/types/invariant.d.ts') + }) + + it('rejects aliases without a supported source target', () => { + expect(() => builtDeclarationPath('./packages/support/invariants/source/index.ts')) + .toThrow('cannot map workspace source path') + }) +}) diff --git a/scripts/doc-typecheck-paths.ts b/scripts/doc-typecheck-paths.ts new file mode 100644 index 0000000000..03b7edb118 --- /dev/null +++ b/scripts/doc-typecheck-paths.ts @@ -0,0 +1,11 @@ +/** Map one workspace source alias target to its declaration-build target. */ +export function builtDeclarationPath(candidate: string): string { + if (candidate.endsWith('/src')) { + return `${candidate.slice(0, -'/src'.length)}/lib/types` + } + const sourceFile = /^(.*)\/src\/(.+)\.ts$/.exec(candidate) + if (sourceFile?.[1] && sourceFile[2]) { + return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts` + } + throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`) +} diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 34af82b48b..bb9b203f9a 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -8,6 +8,7 @@ import { execFileSync } from 'node:child_process' import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import ts from 'typescript' +import { builtDeclarationPath } from './doc-typecheck-paths.ts' import { extractFences } from './md-fences.ts' const root = resolve(import.meta.dirname, '..') @@ -65,12 +66,7 @@ function builtTypeCompilerOptions(): ts.CompilerOptions { if (parsed.options.paths === undefined) throw new Error('doc-typecheck: root tsconfig has no workspace paths') const paths = Object.fromEntries(Object.entries(parsed.options.paths).map(([specifier, candidates]) => [ specifier, - candidates.map((candidate) => { - if (!candidate.endsWith('/src')) { - throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`) - } - return `${candidate.slice(0, -'/src'.length)}/lib/types` - }), + candidates.map(builtDeclarationPath), ])) const options: ts.CompilerOptions = { ...parsed.options, From 433670a75439804f1d6aedde3fbc60a7dc5ca3c3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:13:50 +0800 Subject: [PATCH 03/30] feat(invariants): require package-owned companions --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.md | 2 +- docs/module-graph.md | 251 ++++++++++------ ...06-11-dev-invariants-over-deep-readonly.md | 2 +- .../2026-06-15-turn-enclosure-invariant.md | 2 +- .../2026-07-05-reconstructable-requests.md | 2 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 18 +- ...7-19-package-owned-invariant-service.zh.md | 18 +- .../2026-06-16-typed-event-schemas.md | 6 +- docs/testing.md | 2 + knip.json | 21 +- package.json | 4 +- packages/AGENTS.md | 1 + packages/bash/bash-local/package.json | 7 + packages/bash/bash-local/src/invariant.ts | 30 ++ packages/bash/bash-local/tsconfig.json | 3 + packages/bash/bash-sandbox/package.json | 11 +- packages/bash/bash-sandbox/src/invariant.ts | 30 ++ packages/bash/bash-sandbox/tsconfig.json | 3 + packages/bash/bash/package.json | 7 + packages/bash/bash/src/invariant.ts | 30 ++ packages/bash/bash/tsconfig.json | 3 + packages/bash/tool-bash/package.json | 13 +- packages/bash/tool-bash/src/invariant.ts | 30 ++ packages/bash/tool-bash/tsconfig.json | 3 + .../code-runtime-worker/package.json | 7 + .../code-runtime-worker/src/invariant.ts | 30 ++ .../code-runtime-worker/tsconfig.json | 3 + .../code-runtime-worker/tsdown.config.ts | 2 +- .../code-runtime/code-runtime/package.json | 7 + .../code-runtime/src/invariant.ts | 30 ++ .../code-runtime/code-runtime/tsconfig.json | 3 + packages/compact/compact-basic/package.json | 6 + .../compact/compact-basic/src/invariant.ts | 30 ++ .../compact-basic/tests/compact-basic.spec.ts | 8 +- packages/compact/compact-basic/tsconfig.json | 35 ++- packages/compact/compact/package.json | 7 + packages/compact/compact/src/invariant.ts | 30 ++ packages/compact/compact/tsconfig.json | 3 + packages/context/time-context/package.json | 7 + .../context/time-context/src/invariant.ts | 30 ++ .../time-context/tests/time-context.spec.ts | 5 +- packages/context/time-context/tsconfig.json | 35 ++- .../context/workspace-context/package.json | 7 + .../workspace-context/src/invariant.ts | 30 ++ .../tests/workspace-context.spec.ts | 60 ++-- .../context/workspace-context/tsconfig.json | 3 + packages/cordis/tool-cordis/package.json | 15 +- packages/cordis/tool-cordis/src/invariant.ts | 30 ++ packages/cordis/tool-cordis/tsconfig.json | 3 + packages/core/agent-loop/src/invariant.ts | 6 +- packages/core/agent-loop/tests/resume.spec.ts | 4 +- packages/core/session/src/invariant.ts | 6 +- packages/core/session/tests/session.spec.ts | 23 +- packages/core/system-prompt/package.json | 7 + packages/core/system-prompt/src/invariant.ts | 30 ++ packages/core/system-prompt/tsconfig.json | 3 + packages/core/tools/package.json | 11 +- packages/core/tools/src/invariant.ts | 30 ++ packages/core/tools/tsconfig.json | 3 + packages/examples/acp-demo/package.json | 19 +- packages/examples/acp-demo/src/invariant.ts | 30 ++ packages/examples/acp-demo/tsconfig.json | 3 + packages/examples/acp-demo/tsdown.config.ts | 2 +- .../examples/agent-spine-demo/package.json | 17 +- .../agent-spine-demo/src/invariant.ts | 30 ++ packages/examples/cli-demo/package.json | 7 + packages/examples/cli-demo/src/invariant.ts | 30 ++ packages/examples/cli-demo/tsconfig.json | 43 ++- packages/examples/cli-demo/tsdown.config.ts | 2 +- packages/examples/jsonrpc-demo/package.json | 7 + .../examples/jsonrpc-demo/src/invariant.ts | 30 ++ packages/examples/jsonrpc-demo/tsconfig.json | 3 + .../examples/jsonrpc-demo/tsdown.config.ts | 2 +- packages/examples/stdio-demo/package.json | 25 +- packages/examples/stdio-demo/src/invariant.ts | 30 ++ packages/examples/stdio-demo/tsconfig.json | 3 + packages/examples/stdio-demo/tsdown.config.ts | 2 +- packages/fs/fs-local/package.json | 7 + packages/fs/fs-local/src/invariant.ts | 30 ++ packages/fs/fs-local/tsconfig.json | 23 +- packages/fs/fs-policy/package.json | 7 + packages/fs/fs-policy/src/invariant.ts | 30 ++ packages/fs/fs-policy/tsconfig.json | 19 +- packages/fs/fs/package.json | 7 + packages/fs/fs/src/invariant.ts | 30 ++ packages/fs/fs/tsconfig.json | 19 +- packages/fs/tool-fs-search/package.json | 7 + packages/fs/tool-fs-search/src/invariant.ts | 30 ++ packages/fs/tool-fs-search/tsconfig.json | 43 ++- packages/fs/tool-fs/package.json | 9 +- packages/fs/tool-fs/src/invariant.ts | 30 ++ packages/fs/tool-fs/tsconfig.json | 35 ++- packages/guard/repeat-tool-guard/package.json | 7 + .../guard/repeat-tool-guard/src/invariant.ts | 30 ++ .../guard/repeat-tool-guard/tsconfig.json | 3 + packages/hooks/hook-protocol/package.json | 7 + packages/hooks/hook-protocol/src/invariant.ts | 30 ++ packages/hooks/hook-protocol/tsconfig.json | 3 + packages/hooks/hooks-claude/package.json | 7 + packages/hooks/hooks-claude/src/invariant.ts | 30 ++ .../hooks/hooks-claude/tests/bridge.spec.ts | 13 +- .../hooks-claude/tests/coverage-cases.ts | 15 +- packages/hooks/hooks-claude/tsconfig.json | 3 + packages/hooks/hooks-codex/package.json | 7 + packages/hooks/hooks-codex/src/invariant.ts | 30 ++ packages/hooks/hooks-codex/tsconfig.json | 3 + packages/llm/llm-deepseek/package.json | 7 + packages/llm/llm-deepseek/src/invariant.ts | 30 ++ packages/llm/llm-deepseek/tsconfig.json | 3 + packages/llm/llm-pi-ai/package.json | 7 + packages/llm/llm-pi-ai/src/invariant.ts | 30 ++ packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/llm/llm/package.json | 7 + packages/llm/llm/src/invariant.ts | 30 ++ packages/llm/llm/tsconfig.json | 3 + packages/llm/token-meter/package.json | 7 + packages/llm/token-meter/src/invariant.ts | 30 ++ .../llm/token-meter/tests/token-meter.spec.ts | 13 +- packages/llm/token-meter/tsconfig.json | 3 + packages/mcp/mcp-client/package.json | 11 +- packages/mcp/mcp-client/src/invariant.ts | 30 ++ packages/mcp/mcp-client/tsconfig.json | 23 +- packages/sandbox/sandbox-local/package.json | 7 + .../sandbox/sandbox-local/src/invariant.ts | 30 ++ packages/sandbox/sandbox-local/tsconfig.json | 3 + packages/sandbox/sandbox/package.json | 7 + packages/sandbox/sandbox/src/invariant.ts | 30 ++ packages/sandbox/sandbox/tsconfig.json | 3 + packages/sdk/create-sdk/package.json | 7 + packages/sdk/create-sdk/src/invariant.ts | 30 ++ packages/sdk/create-sdk/tsconfig.json | 11 +- packages/sdk/create-sdk/tsdown.config.ts | 2 +- packages/sdk/helper/package.json | 7 + packages/sdk/helper/src/invariant.ts | 30 ++ packages/sdk/helper/tsconfig.json | 39 ++- packages/sdk/helper/tsdown.config.ts | 2 +- packages/sdk/scripts/package.json | 15 +- packages/sdk/scripts/src/invariant.ts | 30 ++ packages/sdk/scripts/tsconfig.json | 15 +- packages/sdk/scripts/tsdown.config.ts | 4 + .../session-persistence-jsonl/package.json | 7 + .../src/invariant.ts | 30 ++ .../tests/jsonl.spec.ts | 29 +- .../session-persistence-jsonl/tsconfig.json | 3 + .../session-persistence-sqlite/package.json | 7 + .../src/invariant.ts | 30 ++ .../tests/sqlite.spec.ts | 32 +- .../session-persistence-sqlite/tsconfig.json | 3 + .../session-persistence/package.json | 8 + .../session-persistence/src/invariant.ts | 30 ++ .../tests/coordinator-contract.ts | 53 ++-- .../session-persistence/tsconfig.json | 3 + .../session-query/session-query/package.json | 7 + .../session-query/src/invariant.ts | 30 ++ .../session-query/tests/session-query.spec.ts | 32 +- .../session-query/tests/tracing.spec.ts | 59 ++-- .../session-query/session-query/tsconfig.json | 3 + packages/skill/skill-local/package.json | 7 + packages/skill/skill-local/src/invariant.ts | 30 ++ packages/skill/skill-local/tsconfig.json | 27 +- packages/skill/skill/package.json | 7 + packages/skill/skill/src/invariant.ts | 30 ++ packages/skill/skill/tsconfig.json | 15 +- packages/skill/tool-skill/package.json | 7 + packages/skill/tool-skill/src/invariant.ts | 30 ++ packages/skill/tool-skill/tsconfig.json | 35 ++- packages/spill/spill-local/package.json | 7 + packages/spill/spill-local/src/invariant.ts | 30 ++ packages/spill/spill-local/tsconfig.json | 19 +- packages/spill/spill-policy/package.json | 7 + packages/spill/spill-policy/src/invariant.ts | 30 ++ packages/spill/spill-policy/tsconfig.json | 35 ++- packages/spill/spill/package.json | 7 + packages/spill/spill/src/invariant.ts | 30 ++ packages/spill/spill/tsconfig.json | 23 +- packages/subagent/subagent-acp/package.json | 9 +- .../subagent/subagent-acp/src/invariant.ts | 30 ++ packages/subagent/subagent-acp/tsconfig.json | 3 + packages/subagent/subagent-fork/package.json | 8 +- .../subagent/subagent-fork/src/invariant.ts | 30 ++ packages/subagent/subagent-fork/tsconfig.json | 3 + .../subagent/subagent-inprocess/package.json | 6 + .../subagent-inprocess/src/invariant.ts | 30 ++ .../subagent/subagent-inprocess/tsconfig.json | 3 + packages/subagent/subagent-spawn/package.json | 8 +- .../subagent/subagent-spawn/src/invariant.ts | 30 ++ .../subagent/subagent-spawn/tsconfig.json | 3 + .../subagent/subagent-subprocess/package.json | 7 + .../subagent-subprocess/src/invariant.ts | 30 ++ .../subagent-subprocess/tsconfig.json | 6 +- packages/subagent/subagent/package.json | 7 + packages/subagent/subagent/src/invariant.ts | 30 ++ packages/subagent/subagent/tsconfig.json | 3 + packages/subagent/tool-subagent/package.json | 7 + .../subagent/tool-subagent/src/invariant.ts | 30 ++ packages/subagent/tool-subagent/tsconfig.json | 3 + packages/support/acp-snapshot/package.json | 7 + .../support/acp-snapshot/src/invariant.ts | 30 ++ packages/support/acp-snapshot/tsconfig.json | 7 +- .../support/agent-loop-testkit/package.json | 7 + .../agent-loop-testkit/src/invariant.ts | 30 ++ .../support/agent-loop-testkit/tsconfig.json | 3 + packages/support/invariants/README.md | 10 +- packages/support/invariants/package.json | 5 + packages/support/invariants/src/index.ts | 5 +- packages/support/invariants/src/invariant.ts | 30 ++ packages/support/llm-replay/package.json | 7 + packages/support/llm-replay/src/invariant.ts | 30 ++ packages/support/llm-replay/tsconfig.json | 3 + packages/support/loader-smoke/package.json | 7 + .../support/loader-smoke/src/invariant.ts | 30 ++ packages/support/loader-smoke/tsconfig.json | 6 +- packages/tasks/tasks/package.json | 9 +- packages/tasks/tasks/src/invariant.ts | 30 ++ packages/tasks/tasks/tsconfig.json | 3 + packages/tasks/tool-tasks/package.json | 7 + packages/tasks/tool-tasks/src/invariant.ts | 30 ++ packages/tasks/tool-tasks/tsconfig.json | 3 + packages/timeout/timeout-policy/package.json | 7 + .../timeout/timeout-policy/src/invariant.ts | 30 ++ packages/timeout/timeout-policy/tsconfig.json | 27 +- packages/todo/tool-todo/package.json | 7 + packages/todo/tool-todo/src/invariant.ts | 30 ++ packages/todo/tool-todo/tsconfig.json | 3 + packages/ui/acp/package.json | 6 + packages/ui/acp/src/invariant.ts | 30 ++ packages/ui/acp/tsconfig.json | 3 + packages/ui/app-boot/package.json | 7 + packages/ui/app-boot/src/invariant.ts | 30 ++ packages/ui/app-boot/tsconfig.json | 3 + packages/ui/jsonrpc/package.json | 7 + packages/ui/jsonrpc/src/invariant.ts | 30 ++ packages/ui/jsonrpc/tsconfig.json | 3 + packages/ui/permission/package.json | 7 + packages/ui/permission/src/invariant.ts | 30 ++ packages/ui/permission/tsconfig.json | 3 + packages/ui/stdio/package.json | 7 + packages/ui/stdio/src/invariant.ts | 30 ++ packages/ui/stdio/tests/stdio.spec.ts | 112 ++++--- packages/ui/stdio/tsconfig.json | 3 + packages/ui/tool-ask-user/package.json | 7 + packages/ui/tool-ask-user/src/invariant.ts | 30 ++ packages/ui/tool-ask-user/tsconfig.json | 3 + packages/ui/tui/package.json | 7 + packages/ui/tui/src/invariant.ts | 30 ++ packages/ui/tui/tests/harness.ts | 9 +- packages/ui/tui/tests/tui.snapshot.ts | 42 +-- packages/ui/tui/tests/tui.spec.ts | 138 +++++---- packages/ui/tui/tsconfig.json | 3 + packages/ui/user-approval/package.json | 7 + packages/ui/user-approval/src/invariant.ts | 30 ++ .../ui/user-approval/tests/approval.spec.ts | 4 +- packages/ui/user-approval/tsconfig.json | 3 + packages/ui/user-interaction/package.json | 7 + packages/ui/user-interaction/src/invariant.ts | 30 ++ packages/ui/user-interaction/tsconfig.json | 3 + packages/util/brand/package.json | 7 + packages/util/brand/src/invariant.ts | 30 ++ packages/util/brand/tsconfig.json | 6 +- packages/util/home/package.json | 7 + packages/util/home/src/invariant.ts | 30 ++ packages/util/home/tsconfig.json | 6 +- packages/util/paths/package.json | 7 + packages/util/paths/src/invariant.ts | 30 ++ packages/util/paths/tsconfig.json | 6 +- packages/util/retention/package.json | 7 + packages/util/retention/src/invariant.ts | 30 ++ packages/util/retention/tsconfig.json | 6 +- packages/util/timeout/package.json | 7 + packages/util/timeout/src/invariant.ts | 30 ++ packages/util/timeout/tsconfig.json | 6 +- packages/web/tool-web/package.json | 9 +- packages/web/tool-web/src/invariant.ts | 30 ++ packages/web/tool-web/tsconfig.json | 35 ++- packages/web/web-fetch-local/package.json | 7 + packages/web/web-fetch-local/src/invariant.ts | 30 ++ packages/web/web-fetch-local/tsconfig.json | 3 + packages/web/web-search-deepseek/package.json | 7 + .../web/web-search-deepseek/src/invariant.ts | 30 ++ .../web/web-search-deepseek/tsconfig.json | 3 + packages/web/web-search-exa/package.json | 7 + packages/web/web-search-exa/src/invariant.ts | 30 ++ packages/web/web-search-exa/tsconfig.json | 3 + .../web/web-search-perplexity/package.json | 7 + .../web-search-perplexity/src/invariant.ts | 30 ++ .../web/web-search-perplexity/tsconfig.json | 3 + packages/web/web/package.json | 7 + packages/web/web/src/invariant.ts | 30 ++ packages/web/web/tsconfig.json | 3 + packages/workflow/tool-workflow/package.json | 7 + .../workflow/tool-workflow/src/invariant.ts | 30 ++ packages/workflow/tool-workflow/tsconfig.json | 3 + .../workflow-workerthread/package.json | 6 + .../workflow-workerthread/src/invariant.ts | 30 ++ .../workflow-workerthread/tsconfig.json | 3 + .../workflow-workerthread/tsdown.config.ts | 2 +- packages/workflow/workflow/package.json | 7 + packages/workflow/workflow/src/invariant.ts | 30 ++ packages/workflow/workflow/tsconfig.json | 3 + pnpm-lock.yaml | 237 +++++++++++++++ scripts/check-workspace-constraints.ts | 62 +--- scripts/gen-package-invariants.ts | 44 +++ scripts/package-invariants.spec.ts | 104 +++++++ scripts/package-invariants.ts | 283 ++++++++++++++++++ scripts/run-gates.ts | 3 + scripts/test-invariants.spec.ts | 70 +++++ scripts/test-invariants.ts | 104 +++++++ tsdown.config.ts | 9 +- vitest.config.ts | 1 + vitest.e2e.config.ts | 1 + vitest.snapshot.config.ts | 1 + website/zh-CN/api/harness/invariants.md | 4 +- 316 files changed, 5348 insertions(+), 646 deletions(-) create mode 100644 packages/bash/bash-local/src/invariant.ts create mode 100644 packages/bash/bash-sandbox/src/invariant.ts create mode 100644 packages/bash/bash/src/invariant.ts create mode 100644 packages/bash/tool-bash/src/invariant.ts create mode 100644 packages/code-runtime/code-runtime-worker/src/invariant.ts create mode 100644 packages/code-runtime/code-runtime/src/invariant.ts create mode 100644 packages/compact/compact-basic/src/invariant.ts create mode 100644 packages/compact/compact/src/invariant.ts create mode 100644 packages/context/time-context/src/invariant.ts create mode 100644 packages/context/workspace-context/src/invariant.ts create mode 100644 packages/cordis/tool-cordis/src/invariant.ts create mode 100644 packages/core/system-prompt/src/invariant.ts create mode 100644 packages/core/tools/src/invariant.ts create mode 100644 packages/examples/acp-demo/src/invariant.ts create mode 100644 packages/examples/agent-spine-demo/src/invariant.ts create mode 100644 packages/examples/cli-demo/src/invariant.ts create mode 100644 packages/examples/jsonrpc-demo/src/invariant.ts create mode 100644 packages/examples/stdio-demo/src/invariant.ts create mode 100644 packages/fs/fs-local/src/invariant.ts create mode 100644 packages/fs/fs-policy/src/invariant.ts create mode 100644 packages/fs/fs/src/invariant.ts create mode 100644 packages/fs/tool-fs-search/src/invariant.ts create mode 100644 packages/fs/tool-fs/src/invariant.ts create mode 100644 packages/guard/repeat-tool-guard/src/invariant.ts create mode 100644 packages/hooks/hook-protocol/src/invariant.ts create mode 100644 packages/hooks/hooks-claude/src/invariant.ts create mode 100644 packages/hooks/hooks-codex/src/invariant.ts create mode 100644 packages/llm/llm-deepseek/src/invariant.ts create mode 100644 packages/llm/llm-pi-ai/src/invariant.ts create mode 100644 packages/llm/llm/src/invariant.ts create mode 100644 packages/llm/token-meter/src/invariant.ts create mode 100644 packages/mcp/mcp-client/src/invariant.ts create mode 100644 packages/sandbox/sandbox-local/src/invariant.ts create mode 100644 packages/sandbox/sandbox/src/invariant.ts create mode 100644 packages/sdk/create-sdk/src/invariant.ts create mode 100644 packages/sdk/helper/src/invariant.ts create mode 100644 packages/sdk/scripts/src/invariant.ts create mode 100644 packages/session-persistence/session-persistence-jsonl/src/invariant.ts create mode 100644 packages/session-persistence/session-persistence-sqlite/src/invariant.ts create mode 100644 packages/session-persistence/session-persistence/src/invariant.ts create mode 100644 packages/session-query/session-query/src/invariant.ts create mode 100644 packages/skill/skill-local/src/invariant.ts create mode 100644 packages/skill/skill/src/invariant.ts create mode 100644 packages/skill/tool-skill/src/invariant.ts create mode 100644 packages/spill/spill-local/src/invariant.ts create mode 100644 packages/spill/spill-policy/src/invariant.ts create mode 100644 packages/spill/spill/src/invariant.ts create mode 100644 packages/subagent/subagent-acp/src/invariant.ts create mode 100644 packages/subagent/subagent-fork/src/invariant.ts create mode 100644 packages/subagent/subagent-inprocess/src/invariant.ts create mode 100644 packages/subagent/subagent-spawn/src/invariant.ts create mode 100644 packages/subagent/subagent-subprocess/src/invariant.ts create mode 100644 packages/subagent/subagent/src/invariant.ts create mode 100644 packages/subagent/tool-subagent/src/invariant.ts create mode 100644 packages/support/acp-snapshot/src/invariant.ts create mode 100644 packages/support/agent-loop-testkit/src/invariant.ts create mode 100644 packages/support/invariants/src/invariant.ts create mode 100644 packages/support/llm-replay/src/invariant.ts create mode 100644 packages/support/loader-smoke/src/invariant.ts create mode 100644 packages/tasks/tasks/src/invariant.ts create mode 100644 packages/tasks/tool-tasks/src/invariant.ts create mode 100644 packages/timeout/timeout-policy/src/invariant.ts create mode 100644 packages/todo/tool-todo/src/invariant.ts create mode 100644 packages/ui/acp/src/invariant.ts create mode 100644 packages/ui/app-boot/src/invariant.ts create mode 100644 packages/ui/jsonrpc/src/invariant.ts create mode 100644 packages/ui/permission/src/invariant.ts create mode 100644 packages/ui/stdio/src/invariant.ts create mode 100644 packages/ui/tool-ask-user/src/invariant.ts create mode 100644 packages/ui/tui/src/invariant.ts create mode 100644 packages/ui/user-approval/src/invariant.ts create mode 100644 packages/ui/user-interaction/src/invariant.ts create mode 100644 packages/util/brand/src/invariant.ts create mode 100644 packages/util/home/src/invariant.ts create mode 100644 packages/util/paths/src/invariant.ts create mode 100644 packages/util/retention/src/invariant.ts create mode 100644 packages/util/timeout/src/invariant.ts create mode 100644 packages/web/tool-web/src/invariant.ts create mode 100644 packages/web/web-fetch-local/src/invariant.ts create mode 100644 packages/web/web-search-deepseek/src/invariant.ts create mode 100644 packages/web/web-search-exa/src/invariant.ts create mode 100644 packages/web/web-search-perplexity/src/invariant.ts create mode 100644 packages/web/web/src/invariant.ts create mode 100644 packages/workflow/tool-workflow/src/invariant.ts create mode 100644 packages/workflow/workflow-workerthread/src/invariant.ts create mode 100644 packages/workflow/workflow/src/invariant.ts create mode 100644 scripts/gen-package-invariants.ts create mode 100644 scripts/package-invariants.spec.ts create mode 100644 scripts/package-invariants.ts create mode 100644 scripts/test-invariants.spec.ts create mode 100644 scripts/test-invariants.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 76663117b8..4ea9c7a300 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -396,7 +396,7 @@ export interface Config { } ``` -Source: [`packages/support/invariants/src/index.ts:15`](../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:16`](../packages/support/invariants/src/index.ts) ## `@deepseek-ai/dsh-jsonrpc` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5a868e1eff..ca5eb4232b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -495,7 +495,7 @@ Package-owned invariant registry with global and regex-based selection. register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:94`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:95`](../../packages/support/invariants/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 2dc5a1c34e..fb99f5c15e 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -502,7 +502,7 @@ interface TurnEndReasonMap { ## The turn-enclosure invariant -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). +Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-session/invariant` companion enforces it when selected through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ## Plugin-contributed log-only events diff --git a/docs/module-graph.md b/docs/module-graph.md index af8504be18..07ef0f4da9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -150,23 +150,46 @@ flowchart TD pkg_workflow["workflow"] pkg_workflow_workerthread["workflow-workerthread"] end - pkg_llm --> pkg_brand + pkg_brand --> pkg_invariants + pkg_home --> pkg_invariants + pkg_paths --> pkg_invariants + pkg_retention --> pkg_invariants + pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants + pkg_skill --> pkg_invariants + pkg_subagent_subprocess --> pkg_invariants + pkg_acp_snapshot --> pkg_invariants + pkg_loader_smoke --> pkg_invariants + pkg_app_boot --> pkg_invariants + pkg_code_runtime --> pkg_invariants + pkg_jsonrpc_demo --> pkg_invariants + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime + pkg_code_runtime_worker --> pkg_invariants pkg_helper --> pkg_brand + pkg_helper --> pkg_invariants pkg_scripts --> pkg_app_boot + pkg_scripts --> pkg_invariants + pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm + pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm pkg_session --> pkg_scope + pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants pkg_fs --> pkg_llm + pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm + pkg_token_meter --> pkg_invariants pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session pkg_agent --> pkg_brand @@ -175,64 +198,90 @@ flowchart TD pkg_agent --> pkg_scope pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt + pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox pkg_bash --> pkg_session pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants pkg_fs_policy --> pkg_fs + pkg_fs_policy --> pkg_invariants pkg_skill_local --> pkg_fs pkg_skill_local --> pkg_home + pkg_skill_local --> pkg_invariants pkg_skill_local --> pkg_skill + pkg_compact --> pkg_invariants pkg_compact --> pkg_llm pkg_compact --> pkg_session + pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web + pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_web + pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand + pkg_spill --> pkg_invariants pkg_spill --> pkg_llm pkg_spill --> pkg_session + pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session + pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_timeout pkg_compact_basic --> pkg_agent pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_invariants pkg_compact_basic --> pkg_llm pkg_compact_basic --> pkg_session pkg_compact_basic --> pkg_token_meter + pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session + pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence + pkg_session_persistence_sqlite --> pkg_invariants pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand + pkg_user_approval --> pkg_invariants pkg_user_approval --> pkg_llm pkg_user_approval --> pkg_scope pkg_user_approval --> pkg_session pkg_user_approval --> pkg_system_prompt pkg_user_interaction --> pkg_agent + pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm pkg_time_context --> pkg_agent + pkg_time_context --> pkg_invariants pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand + pkg_tasks --> pkg_invariants pkg_tasks --> pkg_session pkg_tasks --> pkg_timeout pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand + pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime + pkg_tools --> pkg_invariants pkg_tools --> pkg_llm pkg_tools --> pkg_scope pkg_tools --> pkg_session @@ -240,8 +289,10 @@ flowchart TD pkg_tools --> pkg_user_approval pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants pkg_bash_sandbox --> pkg_sandbox pkg_permission --> pkg_bash + pkg_permission --> pkg_invariants pkg_permission --> pkg_sandbox pkg_permission --> pkg_session pkg_permission --> pkg_user_approval @@ -256,6 +307,7 @@ flowchart TD pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_bash pkg_tool_bash --> pkg_home + pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_llm pkg_tool_bash --> pkg_sandbox pkg_tool_bash --> pkg_session_persistence @@ -264,11 +316,13 @@ flowchart TD pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval pkg_tool_fs --> pkg_fs + pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_session pkg_tool_fs --> pkg_system_prompt pkg_tool_fs --> pkg_tools pkg_tool_fs_search --> pkg_bash + pkg_tool_fs_search --> pkg_invariants pkg_tool_fs_search --> pkg_llm pkg_tool_fs_search --> pkg_retention pkg_tool_fs_search --> pkg_session @@ -276,45 +330,55 @@ flowchart TD pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_tools pkg_tool_skill --> pkg_agent + pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand + pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_tools + pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools pkg_tool_web --> pkg_web + pkg_spill_policy --> pkg_invariants pkg_spill_policy --> pkg_llm pkg_spill_policy --> pkg_retention pkg_spill_policy --> pkg_session pkg_spill_policy --> pkg_spill pkg_spill_policy --> pkg_tools + pkg_timeout_policy --> pkg_invariants pkg_timeout_policy --> pkg_llm pkg_timeout_policy --> pkg_timeout pkg_timeout_policy --> pkg_tools pkg_tool_todo --> pkg_agent + pkg_tool_todo --> pkg_invariants pkg_tool_todo --> pkg_session pkg_tool_todo --> pkg_tools + pkg_tool_cordis --> pkg_invariants pkg_tool_cordis --> pkg_scope pkg_tool_cordis --> pkg_tools pkg_hooks_codex --> pkg_agent pkg_hooks_codex --> pkg_hook_protocol + pkg_hooks_codex --> pkg_invariants pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools pkg_agent_loop_testkit --> pkg_agent + pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools pkg_acp --> pkg_agent pkg_acp --> pkg_bash + pkg_acp --> pkg_invariants pkg_acp --> pkg_llm pkg_acp --> pkg_permission pkg_acp --> pkg_sandbox @@ -325,51 +389,62 @@ flowchart TD pkg_acp --> pkg_user_approval pkg_acp --> pkg_user_interaction pkg_tool_ask_user --> pkg_agent + pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction pkg_workspace_context --> pkg_agent pkg_workspace_context --> pkg_fs + pkg_workspace_context --> pkg_invariants pkg_workspace_context --> pkg_llm pkg_workspace_context --> pkg_paths pkg_workspace_context --> pkg_session pkg_workspace_context --> pkg_tools pkg_repeat_tool_guard --> pkg_agent + pkg_repeat_tool_guard --> pkg_invariants pkg_repeat_tool_guard --> pkg_tools + pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_tools pkg_tool_tasks --> pkg_agent + pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools pkg_tool_workflow --> pkg_agent + pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow pkg_subagent_acp --> pkg_agent + pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subagent_subprocess pkg_subagent_inprocess --> pkg_agent + pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_tasks pkg_tool_subagent --> pkg_tools pkg_hooks_claude --> pkg_agent pkg_hooks_claude --> pkg_hook_protocol + pkg_hooks_claude --> pkg_invariants pkg_hooks_claude --> pkg_llm pkg_hooks_claude --> pkg_session pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools pkg_jsonrpc --> pkg_agent + pkg_jsonrpc --> pkg_invariants pkg_jsonrpc --> pkg_llm pkg_jsonrpc --> pkg_llm_deepseek pkg_jsonrpc --> pkg_scope @@ -377,11 +452,13 @@ flowchart TD pkg_jsonrpc --> pkg_subagent pkg_stdio --> pkg_agent pkg_stdio --> pkg_agent_loop + pkg_stdio --> pkg_invariants pkg_stdio --> pkg_llm pkg_stdio --> pkg_session pkg_stdio --> pkg_user_interaction pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop + pkg_tui --> pkg_invariants pkg_tui --> pkg_llm pkg_tui --> pkg_session pkg_tui --> pkg_tools @@ -404,20 +481,24 @@ flowchart TD pkg_agent_spine_demo --> pkg_workspace_context pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_brand + pkg_workflow_workerthread --> pkg_invariants 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 pkg_subagent_fork --> pkg_agent + pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session pkg_subagent_fork --> pkg_subagent pkg_subagent_fork --> pkg_subagent_inprocess + pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot + pkg_acp_demo --> pkg_invariants pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction @@ -425,6 +506,7 @@ flowchart TD pkg_cli_demo --> pkg_agent pkg_cli_demo --> pkg_agent_spine_demo pkg_cli_demo --> pkg_app_boot + pkg_cli_demo --> pkg_invariants pkg_cli_demo --> pkg_llm pkg_cli_demo --> pkg_session pkg_cli_demo --> pkg_session_persistence_jsonl @@ -434,6 +516,7 @@ flowchart TD pkg_stdio_demo --> pkg_agent_loop pkg_stdio_demo --> pkg_agent_spine_demo pkg_stdio_demo --> pkg_app_boot + pkg_stdio_demo --> pkg_invariants pkg_stdio_demo --> pkg_llm pkg_stdio_demo --> pkg_session pkg_stdio_demo --> pkg_session_persistence_jsonl @@ -447,92 +530,92 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | -| [`brand`](../packages/util/brand) | `util` | — | -| [`home`](../packages/util/home) | `util` | — | -| [`paths`](../packages/util/paths) | `util` | — | -| [`retention`](../packages/util/retention) | `util` | — | -| [`timeout`](../packages/util/timeout) | `util` | — | -| [`skill`](../packages/skill/skill) | `skill` | — | -| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | -| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`invariants`](../packages/support/invariants) | `support` | — | -| [`loader-smoke`](../packages/support/loader-smoke) | `support` | — | -| [`app-boot`](../packages/ui/app-boot) | `ui` | — | -| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | -| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | — | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | +| [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | +| [`home`](../packages/util/home) | `util` | [`invariants`](../packages/support/invariants) | +| [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | +| [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | +| [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | -| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | -| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | -| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | +| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | +| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | +| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | +| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants) | +| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | +| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | +| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) | +| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`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) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`bash`](../packages/bash/bash) | `bash` | [`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) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`skill`](../packages/skill/skill) | -| [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) | -| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`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), [`token-meter`](../packages/llm/token-meter) | -| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | -| [`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) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`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) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | -| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`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) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | +| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) | +| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`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), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`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), [`brand`](../packages/util/brand), [`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) | -| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | -| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | -| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | -| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`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), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | +| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | +| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`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-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`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), [`workspace-context`](../packages/context/workspace-context) | -| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | 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 9e272456fc..4b77930280 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 @@ -30,7 +30,7 @@ This guarantee belongs in `Session`, not in an optional listener, because every ### Package-owned invariant companions check relationships -`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Optional `./invariant` companions from `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` own the rules that require trace state or observation of another seam: 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. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)). +`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Every package publishes a `./invariant` ownership companion; `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` currently add the rules that require trace state or observation of another seam: 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. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)). When the session companion attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. The service gives each contribution a disposable child fiber, so hot reload is safe in the middle of a turn without giving diagnostics ownership of session storage. diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 81b1bd25df..4ee29e8fb8 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -21,7 +21,7 @@ In case 2, if the injected `context/message` is the last event before a flush/di - An `agent.inject()` made while the agent is **running** joins the already-open turn. While the current step executes assistant tool calls, accepted context waits in arrival order until that batch settles, then appends after every recorded result and before the turn closes even when execution is interrupted. - An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`. - The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number. -- The optional `dsh-session/invariant` companion registers the dev check with `ctx.invariants`: a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError` attributed to `@deepseek-ai/dsh-session`. +- The `dsh-session/invariant` companion registers the check with `ctx.invariants`: when selected, a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError` attributed to `@deepseek-ai/dsh-session`. The serializability invariant is enforced at the same source boundary (`Session.append` throws on non-JSON-serializable data), so "what may enter the log" is now governed in one place rather than discovered downstream by whichever backend happens to be watching. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index e86d6b9610..62630152c9 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session **`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. -**Enforcement.** In development, the optional `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. +**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index e7e43cc1eb..2be6be1c53 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -322,7 +322,7 @@ TypeScript cannot govern JavaScript casts, direct Cordis dispatch, process messa ### Runtime invariants cover cross-service facts -The optional `dsh-scope/invariant` companion verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. The separate `dsh-session/invariant` contribution stages trace validation before append commit and advances after the same event commits; both register through `ctx.invariants`. +The `dsh-scope/invariant` companion verifies, when selected, that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. The separate `dsh-session/invariant` contribution stages trace validation before append commit and advances after the same event commits; both register through `ctx.invariants`. The plugin does not police trusted setup by scanning registries or reject prompt assembly objects fabricated through casts. Those checks would turn composition contracts into speculative runtime machinery without protecting a real external boundary. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index ce8fabdc40..dfe64b7110 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: f147cd91134509e17df39d1acbb28ae8b521c2f8 -2026-07-19-package-owned-invariant-service.zh.md: a106b57137c36009d0d4b4d39f109b7abf857f42 +2026-07-19-package-owned-invariant-service.md: bf27bb4e951988bc124509b49dea9b5509f8e6f3 +2026-07-19-package-owned-invariant-service.zh.md: c9dfbb65bf4fd497f2f592f7741923dcef3e51fb diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md index f147cd9113..bf27bb4e95 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -10,13 +10,15 @@ Runtime invariant checks span session traces, agent state, scoped dispatch, and Deployments also need more than presence or absence of one plugin. A standard composition should carry the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently. +Package ownership must also be exhaustive. Without a mechanical repository rule, a new package can omit the companion, dependency, or publication wiring and remain invisible to diagnostics until a maintainer notices the gap. + ## Decision ### One registry service, package-owned contributions `@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. -Packages expose diagnostics through optional `./invariant` companion plugins. Their root entrypoints do not import or register diagnostics implicitly, so loading a product package does not change runtime checking or require the invariant service. +Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A package with no relational check uses a generated ownership-only installer: it reserves the name through the real service boundary but installs no listeners. Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. ### Configuration and selection @@ -53,7 +55,7 @@ Registration setup is transactional. If an installer fails after registering lis The former functional-plugin entrypoint and one-argument `InvariantError` constructor are not retained as compatibility surfaces. The repository is pre-release and all call sites move to the service and package-attributed error together. -### Shipped companions +### Stateful companions and exhaustive ownership | Companion entry | Registration name | Owned checks | |---|---|---| @@ -62,7 +64,9 @@ The former functional-plugin entrypoint and one-argument `InvariantError` constr | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -Each owner contains its source and focused tests. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape. The service package retains only registry tests. +These four owners contain stateful checks and focused tests. Every other package carries a generated baseline companion until it gains a relational assertion. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. + +`verify-package-invariants` discovers every workspace package and rejects missing or stale companion source, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. The generator writes only missing or marked ownership baselines, so a package-owned implementation is never replaced. ### Scoped-event semantic map @@ -70,7 +74,7 @@ The generated scoped-event subject resolver lives in `dsh-scope`, beside the con ### Standard composition and SDK output -The standard agent spine mounts the service and all four companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. +The standard agent spine mounts the service and all four stateful companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication surface. Generated config catalogs, module graphs, and API documentation derive from those sources. @@ -80,18 +84,22 @@ Service tests cover defaults, global disablement, allow/block selection, blockli Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. +Every Vitest configuration loads a test host that mounts an explicitly enabled service and all package companions before an ordinary Cordis root's first plugin. Focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. + ## Alternatives considered - **Keep all checks in `dsh-invariants`.** Rejected because the registry would continue importing every checked product domain, owner changes would require central edits, and package tests would remain detached from the contracts they protect. - **Let root package entrypoints register checks implicitly when `ctx.invariants` happens to exist.** Rejected because root behavior would depend on composition order and optional service presence, diagnostics could not be selected independently, and package loading would hide a registration effect outside an explicit companion. -- **Discover every `invariant.ts` file automatically.** Rejected because filesystem/package discovery is not a runtime ownership contract, makes bundled publication ambiguous, and cannot express explicit Cordis load order or dependency installation. +- **Discover every `invariant.ts` file automatically at runtime.** Rejected because filesystem/package discovery is not a runtime ownership contract, makes bundled publication ambiguous, and cannot express explicit Cordis load order or dependency installation. Build-time generation, verification, and the test host may enumerate the source tree because they validate repository completeness rather than composing a shipped deployment. - **Validate allow/block entries against the currently loaded package set.** Rejected because a zero-match pattern can intentionally target a later or HMR-loaded contribution; current load order must not determine config validity. ## Consequences - Product packages own and test their relational assertions while the service stays product-independent. +- Every package pays the small publication and dependency cost of an invariant companion, including packages whose generated baseline currently installs no listeners. - Standard compositions can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. +- Ordinary Vitest roots install every selected companion, trading extra child fibers during tests for repository-wide invariant coverage and immediate fixture failures. - Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index a106b57137..c9dfbb65bf 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -10,13 +10,15 @@ Status: implemented 部署还需要比“是否加载一个插件”更细的控制。标准组合应携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR 下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。 +包所有权还必须覆盖完整。若没有机械化的仓库规则,新包可能遗漏伴随插件、依赖或发布配置,并一直不会进入诊断范围,直到维护者发现这一缺口。 + ## 决策 ### 一个注册服务,贡献归包所有 `@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 -各包通过可选的 `./invariant` 伴随插件公开诊断。根入口不会隐式导入或注册诊断,因此加载产品包本身不会改变运行时检查,也不要求不变式服务存在。 +工作区内的每个包都发布 `./invariant` 伴随插件,并注册自己完整且准确的 npm 包名。没有关系检查的包使用生成的仅声明所有权 installer:它通过真实服务边界占用包名,但不安装监听器。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 ### 配置与选择 @@ -53,7 +55,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容表面保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。 -### 已提供的伴随插件 +### 有状态伴随插件与完整所有权 | 伴随入口 | 注册名 | 所属检查 | |---|---|---| @@ -62,7 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -每个所有者都保存自己的源码与聚焦测试。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态。服务包只保留注册服务测试。 +这四个所有者保存有状态检查与聚焦测试。其他每个包在获得关系断言之前,都带有生成的基线伴随插件。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 + +`verify-package-invariants` 会发现每个工作区包,并拒绝缺失或陈旧的伴随插件源码、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。生成器只写入缺失或带生成标记的所有权基线,因此绝不会替换包自行维护的实现。 ### Scoped event 语义映射 @@ -70,7 +74,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 ### 标准组合与 SDK 输出 -标准 agent spine 会挂载服务和四个伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。 +标准 agent spine 会挂载服务和四个有状态伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。 Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一发布表面。生成的配置目录、模块图和 API 文档都从这些源派生。 @@ -80,18 +84,22 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 +每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务以及所有包的伴随插件。服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 + ## 考虑过的替代方案 - **把所有检查保留在 `dsh-invariants`。** 不予采纳,因为注册包仍要导入所有被检查的产品领域,所有者变更仍需中央编辑,测试也继续远离被保护的契约。 - **当 `ctx.invariants` 恰好存在时,让根包入口隐式注册检查。** 不予采纳,因为根入口行为会依赖组合顺序与可选服务是否存在,诊断无法独立选择,而且包加载会隐藏一个不在显式伴随插件中的注册 effect。 -- **自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。 +- **在运行时自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。构建期生成与校验以及测试 host 可以枚举源码树,因为它们验证的是仓库完整性,而不是组合已发布的部署。 - **根据当前已加载包集合验证 allow/block 条目。** 不予采纳,因为零匹配模式可能有意指向稍后加载或 HMR 加载的贡献;当前加载顺序不能决定配置有效性。 ## 后果 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 +- 每个包都要承担不变式伴随插件带来的少量发布与依赖成本,包括目前只安装生成基线、不添加监听器的包。 - 标准组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 +- 普通 Vitest 根上下文会安装每个被选中的伴随插件,以增加测试期间的子 fiber 为代价,换取覆盖整个仓库的不变式检查和对 fixture(测试前置数据)错误的即时反馈。 - 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index 5db00851ae..026273c088 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -28,7 +28,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim - **Six merge-extensible maps** (~370 LOC of core types): `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap` (in `dsh-llm`); `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap` (in `dsh-session`). - **~10 `declare module` augmentation sites** across `dsh-agent`, `dsh-agent-loop`, `dsh-bash`, `dsh-llm`, `dsh-session`, `dsh-session-persistence`, `dsh-system-prompt`, `dsh-tools` — each would move from declaration merging to a runtime `register()` call. - **The event producers** — 16 `session.append(...)` call sites in the loop — unchanged in shape but now validated at the boundary. -- **~7 switch-consumers** that branch on these unions: `deriveMessages` and the optional invariant companion (`dsh-session`), `BlockAssembler` (`dsh-llm`), both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive. +- **~7 switch-consumers** that branch on these unions: `deriveMessages` and the package-owned invariant companion (`dsh-session`), `BlockAssembler` (`dsh-llm`), both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive. - **The `defineTool` `InferArgs` DSL** (`dsh-tools`), which derives zero-cast `execute` arg types from a compile-time schema spec — the showcase of the current approach. - **Docs**: architecture.md (the pattern is described as foundational), [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md), and any RFC that references the pattern. @@ -37,7 +37,7 @@ This is a repository-wide vocabulary redesign, not a persistence implementation ## Alternatives considered ### A. Status quo — merge-extensible types + `isJsonValue` at the durable boundary -Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility and is enforced by TypeScript at compile time. Package-owned invariant companions check selected cross-record relationships in dev but do not provide general runtime shape schemas. +Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility and is enforced by TypeScript at compile time. Package-owned invariant companions check selected cross-record relationships when enabled but do not provide general runtime shape schemas. - **Pros**: zero churn; plugin extension is a one-line `interface` augmentation with full type inference and no runtime registration ceremony; no new runtime dependency; the `defineTool` DSL and `assertNever` exhaustiveness keep working. - **Cons**: no runtime structural validation at the persistence boundary or at plugin seams; a malformed-but-JSON datum is caught late. @@ -72,4 +72,4 @@ Defer. If runtime validation is wanted at the durable boundary, **Option B** (sc - If a registry is adopted, is the library **schemastery** (already in the tree, already the config schema lib) or **Zod** (richer ecosystem, currently only transitive)? Adopting two schema libraries is a cost in itself. - Can a hybrid keep compile-time inference (so `defineTool` and plugin DX survive) while adding an *optional* runtime schema per variant, validated only at the persistence/wire boundary rather than on every in-process append? -- Does the `dsh-invariants` plugin already cover enough of the runtime-shape gap in dev that boundary validation is only needed for genuinely untrusted input (reload of an externally-modified log)? +- Does the `ctx.invariants` service already cover enough of the runtime-shape gap when enabled that boundary validation is only needed for genuinely untrusted input (reload of an externally-modified log)? diff --git a/docs/testing.md b/docs/testing.md index cd3b1aa9e8..97ec20e99f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,6 +9,8 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless goldens cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state goldens; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot RFC](rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and golden diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +All Vitest configurations mount enabled `ctx.invariants` and every package companion before ordinary Cordis roots start. Focused invariant topology tests compose enabled or deliberately disabled services explicitly, without competing global registrations. + ## The with-key policy: inference is cheap here We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships a keyless smoke and — unless keyless-by-nature — a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). diff --git a/knip.json b/knip.json index 2c3465ab38..3735303d8f 100644 --- a/knip.json +++ b/knip.json @@ -35,23 +35,19 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/util/brand": { - "project": ["src/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts"] }, "packages/util/home": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/util/timeout": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/util/retention": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/support/acp-snapshot": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"], @@ -59,8 +55,7 @@ }, "packages/support/loader-smoke": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/core/agent-loop": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], @@ -84,8 +79,7 @@ }, "packages/util/paths": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/web/web-search-exa": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], @@ -149,8 +143,7 @@ }, "packages/subagent/subagent-subprocess": { "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/fs/tool-fs": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], diff --git a/package.json b/package.json index e736e3f8d1..abf1e59178 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", + "gen-package-invariants": "tsx scripts/gen-package-invariants.ts", + "verify-package-invariants": "tsx scripts/gen-package-invariants.ts --check", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", @@ -77,7 +79,7 @@ "website:build": "pnpm --filter @deepseek-ai/website run build", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-website-api && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run verify-website-yaml", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/repl-agent/cordis.yml", "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index e4b5ded6a3..1735c44978 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,6 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. +- **Every package owns an invariant companion.** Publish `./invariant`, register the manifest's exact npm name, and keep the generated ownership baseline until relational checks exist. `verify-package-invariants` gates source and publication wiring ([rationale](../docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). Naming notes: diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index 381855b465..152c2db28b 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -31,6 +37,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts new file mode 100644 index 0000000000..8af5f91b22 --- /dev/null +++ b/packages/bash/bash-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-bash-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-bash-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local' + +/** Cordis companion plugin name. */ +export const name = 'bash-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index 02448770f4..a55c76f00a 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../bash/bash" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index b4f61abcbe..42ba34c6a2 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-bash-local": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -33,9 +39,10 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "node-addon-landlock-run": "0.0.0-test.0", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "node-addon-landlock-run": "0.0.0-test.0" } } diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts new file mode 100644 index 0000000000..e74190fa82 --- /dev/null +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-bash-sandbox`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-bash-sandbox/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'bash-sandbox-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash-sandbox/tsconfig.json b/packages/bash/bash-sandbox/tsconfig.json index 6dad98d54f..0b0e350ce9 100644 --- a/packages/bash/bash-sandbox/tsconfig.json +++ b/packages/bash/bash-sandbox/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../bash/bash-local" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 0783995161..8bee74f632 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -11,22 +11,29 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts new file mode 100644 index 0000000000..350f68bfc7 --- /dev/null +++ b/packages/bash/bash/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-bash`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-bash/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-bash' + +/** Cordis companion plugin name. */ +export const name = 'bash-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index b9ab094edb..0e228ada67 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 816be1ef88..ec17dbacdb 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,15 +28,16 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -41,10 +47,10 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -54,6 +60,7 @@ "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts new file mode 100644 index 0000000000..286089c7ff --- /dev/null +++ b/packages/bash/tool-bash/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-bash`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-bash/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash' + +/** Cordis companion plugin name. */ +export const name = 'tool-bash-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index 407e78ebd8..6854bb9f7e 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../sandbox/sandbox" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index 77169f8eab..f9c0f6be4c 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./worker": { "types": "./lib/types/worker.d.ts", "default": "./lib/worker.cjs" @@ -19,6 +23,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -27,6 +32,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-code-runtime": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -34,6 +40,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts new file mode 100644 index 0000000000..41b3eab511 --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-code-runtime-worker`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-code-runtime-worker/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker' + +/** Cordis companion plugin name. */ +export const name = 'code-runtime-worker-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime-worker/tsconfig.json b/packages/code-runtime/code-runtime-worker/tsconfig.json index af962eda4f..4a201c70e1 100644 --- a/packages/code-runtime/code-runtime-worker/tsconfig.json +++ b/packages/code-runtime/code-runtime-worker/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../code-runtime" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/code-runtime/code-runtime-worker/tsdown.config.ts b/packages/code-runtime/code-runtime-worker/tsdown.config.ts index 6fee724195..1c40637722 100644 --- a/packages/code-runtime/code-runtime-worker/tsdown.config.ts +++ b/packages/code-runtime/code-runtime-worker/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown' */ export default defineConfig([ { - entry: ['lib/types/index.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 5380d26ace..2d59302ec9 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts new file mode 100644 index 0000000000..6102927d77 --- /dev/null +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-code-runtime`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-code-runtime/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime' + +/** Cordis companion plugin name. */ +export const name = 'code-runtime-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/tsconfig.json b/packages/code-runtime/code-runtime/tsconfig.json index 754725418e..9966c8ca8a 100644 --- a/packages/code-runtime/code-runtime/tsconfig.json +++ b/packages/code-runtime/code-runtime/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index a0ee2b4036..44274238de 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-compact": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts new file mode 100644 index 0000000000..f72c0e5898 --- /dev/null +++ b/packages/compact/compact-basic/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-compact-basic`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-compact-basic/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-compact-basic' + +/** Cordis companion plugin name. */ +export const name = 'compact-basic-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 0a411440b3..31992793f0 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -10,7 +10,7 @@ import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@d import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal const MODEL = 'test-model' @@ -791,7 +791,7 @@ describe('default one-shot summarizer', () => { describe('automatic listener and loader composition', () => { function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise { - return ctx.serial('agent/post-step', owner, 1, 1, signal) + return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal) } function recover( @@ -802,7 +802,9 @@ describe('automatic listener and loader composition', () => { signal = SIGNAL, next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }), ): Promise<{ action: 'fail' | 'retry' }> { - return ctx.waterfall('agent/request-error', owner, 1, 1, error, retryAttempt, signal, next) + return agentEvents(ctx, owner).waterfall( + 'agent/request-error', 1, 1, error, retryAttempt, signal, next, + ) } function overflow(message = 'provider overflow'): Error & { code: string } { diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json index 0103ad82a8..a009b9ecb8 100644 --- a/packages/compact/compact-basic/tsconfig.json +++ b/packages/compact/compact-basic/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../llm/token-meter" }, - { "path": "../../core/session" }, - { "path": "../../core/agent" }, - { "path": "../compact" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../llm/token-meter" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../compact" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 985c42d3b2..135c688507 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -11,22 +11,29 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts new file mode 100644 index 0000000000..f7a2cbffd1 --- /dev/null +++ b/packages/compact/compact/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-compact`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-compact/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-compact' + +/** Cordis companion plugin name. */ +export const name = 'compact-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/compact/compact/tsconfig.json b/packages/compact/compact/tsconfig.json index 95245937ec..673ee51547 100644 --- a/packages/compact/compact/tsconfig.json +++ b/packages/compact/compact/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index c0d69a75cb..ffbc3a87ee 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -26,12 +31,14 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts new file mode 100644 index 0000000000..64fb98ac81 --- /dev/null +++ b/packages/context/time-context/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-time-context`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-time-context/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-time-context' + +/** Cordis companion plugin name. */ +export const name = 'time-context-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 06ae13818d..07c2fe54ae 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -4,8 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -83,7 +82,7 @@ async function fire( step: number, signal: AbortSignal = SIGNAL, ): Promise { - await ctx.serial('agent/pre-step', agent, turn, step, signal) + await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, signal) } function textResponse(text: string): StreamChunk[] { diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index 7815242b55..491552def0 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../core/agent" }, - { "path": "../../core/system-prompt" }, - { "path": "../../core/agent" }, - { "path": "../../support/loader-smoke" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../support/loader-smoke" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 7f704c838a..0c50b8cc17 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -39,6 +45,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts new file mode 100644 index 0000000000..f3e76225c0 --- /dev/null +++ b/packages/context/workspace-context/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-workspace-context`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-workspace-context/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context' + +/** Cordis companion plugin name. */ +export const name = 'workspace-context-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 2ff2067cf5..bdeeabe810 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,8 +7,9 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -23,7 +24,12 @@ import type { import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import type { + PostToolDecision, + ToolExecution, + ToolExecutionResult, + ToolExecutionToken, +} from '@deepseek-ai/dsh-tools' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { discoverBaselineInstructionFiles, @@ -228,14 +234,31 @@ const composedPrefixes = new WeakMap() async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise { const empty: Message[] = [] - const prefix = await ctx.waterfall( - 'agent/session-prefix', agent, empty, AbortSignal.timeout(1000), + const prefix = await agentEvents(ctx, agent).waterfall( + 'agent/session-prefix', empty, AbortSignal.timeout(1000), () => Promise.resolve(empty), ) composedPrefixes.set(agent, prefix) return prefix } +function toolEventCarrier(ctx: Context, exec: ToolExecution) { + return scopeTarget(ctx.get('tools') ?? ctx as unknown as ToolRegistry, exec.agent) +} + +function postExecute( + ctx: Context, + exec: ToolExecution, + result: Readonly, + next: () => Promise, +): Promise { + return ctx.waterfall(toolEventCarrier(ctx, exec), 'tools/post-execute', exec, result, next) +} + +function emitToolResult(ctx: Context, exec: ToolExecution, result: Readonly): void { + ctx.emit(toolEventCarrier(ctx, exec), 'tools/result', exec, result) +} + function derivedText(agent: Agent): string { return blocksText(composedPrefixes.get(agent)?.[0]?.content) } @@ -796,7 +819,7 @@ describe('workspace context request injection', () => { try { await ctx.plugin(workspaceContext, { maxBytes: 65536 }) - const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ + const decision = await postExecute(ctx, stubToolExecution({ callId: CallId('no-fs-post-execute'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, @@ -843,7 +866,7 @@ describe('workspace context request injection', () => { } // A later PostToolUse-style policy blocks this otherwise-successful read. - const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ + const blocked = await postExecute(ctx, exec, result, async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'blocked by policy' }], })) @@ -857,7 +880,7 @@ describe('workspace context request injection', () => { // The same read, when the downstream accepts, DOES surface the nested // instructions — proving the block branch above is what suppressed them, // and that the block did not consume the pending nested change. - const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ + const accepted = await postExecute(ctx, exec, result, async () => ({ kind: 'accept' as const, })) expect(accepted.kind).toBe('accept') @@ -1169,8 +1192,9 @@ describe('workspace context request injection', () => { const controller = new AbortController() const reason = new Error('cancel prefix') const empty: Message[] = [] - const pending = ctx.waterfall( - 'agent/session-prefix', stubAgent(root), empty, controller.signal, + const agent = stubAgent(root) + const pending = agentEvents(ctx, agent).waterfall( + 'agent/session-prefix', empty, controller.signal, () => Promise.resolve(empty), ) @@ -1660,7 +1684,7 @@ describe('dynamic nested workspace context injection', () => { signal: controller.signal, }) - const pending = ctx.waterfall('tools/post-execute', exec, { + const pending = postExecute(ctx, exec, { content: [{ type: 'text', text: 'ok' }], isError: false, }, () => Promise.resolve({ kind: 'accept' as const })) @@ -2387,12 +2411,12 @@ describe('dynamic nested workspace context injection', () => { isError: false, } - const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ + const failedStat = await postExecute(ctx, stubToolExecution({ callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) fs.throwOnStat.clear() fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' }) - const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ + const mismatchedStat = await postExecute(ctx, stubToolExecution({ callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) @@ -2626,19 +2650,19 @@ describe('dynamic nested workspace context injection', () => { const parent = Symbol('parent') as ToolExecutionToken const plainResult = { callId: CallId('plain'), content: [], isError: false } - ctx.emit('tools/result', stubToolExecution({ + emitToolResult(ctx, stubToolExecution({ callId: CallId('agentless-child'), name: 'read', arguments: {}, parent, }), plainResult) - ctx.emit('tools/result', stubToolExecution({ + emitToolResult(ctx, stubToolExecution({ callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] }) - ctx.emit('tools/result', stubToolExecution({ + emitToolResult(ctx, stubToolExecution({ callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] }) - ctx.emit('tools/result', stubToolExecution({ + emitToolResult(ctx, stubToolExecution({ callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] }) - ctx.emit('tools/result', { + emitToolResult(ctx, { ...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }), token: parent, }, plainResult) @@ -2674,7 +2698,7 @@ describe('dynamic nested workspace context injection', () => { ] for (const item of cases) { - const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ + const decision = await postExecute(ctx, stubToolExecution({ callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`), name: item.name, arguments: item.arguments, diff --git a/packages/context/workspace-context/tsconfig.json b/packages/context/workspace-context/tsconfig.json index b4807ded65..b5aca1dfc8 100644 --- a/packages/context/workspace-context/tsconfig.json +++ b/packages/context/workspace-context/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../util/paths" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/cordis/tool-cordis/package.json b/packages/cordis/tool-cordis/package.json index e13de3e58b..7e7b75cab3 100644 --- a/packages/cordis/tool-cordis/package.json +++ b/packages/cordis/tool-cordis/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,16 +36,17 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "cordis": "^4.0.0-rc.7", - "@cordisjs/plugin-timer": "workspace:^" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/cordis/tool-cordis/src/invariant.ts b/packages/cordis/tool-cordis/src/invariant.ts new file mode 100644 index 0000000000..a3f375b69f --- /dev/null +++ b/packages/cordis/tool-cordis/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-cordis`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-cordis/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis' + +/** Cordis companion plugin name. */ +export const name = 'tool-cordis-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/cordis/tool-cordis/tsconfig.json b/packages/cordis/tool-cordis/tsconfig.json index cc9928d81f..4f10b49622 100644 --- a/packages/cordis/tool-cordis/tsconfig.json +++ b/packages/cordis/tool-cordis/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index 41b9112a4e..ab6a885dc0 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -12,8 +12,8 @@ const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop' /** Cordis companion plugin name. */ export const name = 'agent-loop-invariant' -/** Services required before the companion can register. */ -export const inject = ['invariants', 'sessions'] +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] /** Install the request-reconstruction contribution into its child registration fiber. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { @@ -66,7 +66,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant /** * Register the agent-loop invariant companion. - * @param ctx - Cordis context carrying the invariant and session services. + * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ export const apply = (ctx: Context): Promise<() => void> => diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 74192dde90..be98eb8447 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -425,7 +425,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length }, }) - await ctx1.parallel('session/flush', forked) + await ctx1.sessions.flush(forked) await ctx1.fiber.dispose() // Lifecycle 2: resume it; the parentSession + seedLength header survives the @@ -482,7 +482,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) - await ctx1.parallel('session/flush', a1.session) + await ctx1.sessions.flush(a1.session) await ctx1.fiber.dispose() // Lifecycle 2: resume; the injected context is still in the derived history. diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 2887d6f9f0..22b567183e 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -15,8 +15,8 @@ const PACKAGE_NAME = '@deepseek-ai/dsh-session' /** Cordis companion plugin name. */ export const name = 'session-invariant' -/** Services required before the companion can register. */ -export const inject = ['invariants', 'sessions'] +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] /** Per-session bookkeeping for relational log checks. */ interface SessionTrace { @@ -223,7 +223,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant /** * Register the session invariant companion. - * @param ctx - Cordis context carrying the invariant and session services. + * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ export const apply = (ctx: Context): Promise<() => void> => diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 3b15157bde..a9addbf1cb 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -718,10 +718,11 @@ describe('SessionStore', () => { // may create an unrelated property with the old implementation's name, // but cannot suppress the durable event feed. expect(Reflect.set(session, 'onAppend', undefined)).toBe(true) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(events).toHaveLength(1) - expect(events[0]![0]).toBe(session) - expect(events[0]![1].type).toBe('user/message') + expect(events).toHaveLength(2) + expect(events[1]![0]).toBe(session) + expect(events[1]![1].type).toBe('user/message') expect(ctx.sessions.get(session.id)).toBe(session) expect(ctx.sessions.list()).toEqual([session]) @@ -733,6 +734,7 @@ describe('SessionStore', () => { const a = ctx.sessions.create(SessionId('fixed')) expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists') + a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) @@ -958,8 +960,9 @@ describe('SessionStore', () => { ctx.on('session/event', (_session, event) => void events.push(event)) const session = ctx.sessions.create(SessionId('fixed')) expect(ctx.sessions.get(SessionId('fixed'))).toBe(session) + 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' }) - expect(events).toHaveLength(1) + expect(events.at(-1)?.type).toBe('user/message') }) it('contains session/event observer failures after the append commit point', async () => { @@ -1043,6 +1046,8 @@ describe('SessionStore', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('surface-dispatch-veto')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' }, @@ -1062,19 +1067,19 @@ describe('SessionStore', () => { step: 1, content: [{ type: 'text', text: 'replacement' }], }, { - surfaceOp: { op: 'replace', start: 0, end: 0 }, - sourceEventSeqs: [0], + surfaceOp: { op: 'replace', start: 2, end: 2 }, + sourceEventSeqs: [2], })).toThrow('reject surface candidate') - expect(session.events).toHaveLength(1) - expect(surface.nodes).toEqual([0]) + expect(session.events).toHaveLength(3) + expect(surface.nodes).toEqual([2]) expect(surface.replaceGeneration).toBe(0) session.append('user/message', { content: [{ type: 'text', text: 'next' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - expect(surface.nodes).toEqual([0, 1]) + expect(surface.nodes).toEqual([2, 3]) expect(surface.replaceGeneration).toBe(0) }) diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 67161b79b4..6cf94f477e 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts new file mode 100644 index 0000000000..5117f93ce0 --- /dev/null +++ b/packages/core/system-prompt/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-system-prompt`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-system-prompt/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-system-prompt' + +/** Cordis companion plugin name. */ +export const name = 'system-prompt-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json index 91e7bf1ba4..c7de9a1b69 100644 --- a/packages/core/system-prompt/tsconfig.json +++ b/packages/core/system-prompt/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 2fe3cbd448..535e60f60b 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,12 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-code-runtime": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^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-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -36,12 +42,13 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts new file mode 100644 index 0000000000..7c5743e1d4 --- /dev/null +++ b/packages/core/tools/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tools`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tools/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tools' + +/** Cordis companion plugin name. */ +export const name = 'tools-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json index c94b270c8c..918112d7d0 100644 --- a/packages/core/tools/tsconfig.json +++ b/packages/core/tools/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../ui/user-approval" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 28d057c0a0..6743c55a3e 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./bin": { "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" @@ -23,6 +27,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -32,28 +37,30 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", + "@deepseek-ai/dsh-app-boot": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" }, "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" } diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts new file mode 100644 index 0000000000..d8f2ff17dc --- /dev/null +++ b/packages/examples/acp-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-acp-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-acp-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-acp-demo' + +/** Cordis companion plugin name. */ +export const name = 'acp-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index b0e537574a..2f6acd4a17 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/examples/acp-demo/tsdown.config.ts b/packages/examples/acp-demo/tsdown.config.ts index 9dd130b30d..2fa93780be 100644 --- a/packages/examples/acp-demo/tsdown.config.ts +++ b/packages/examples/acp-demo/tsdown.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * matching every package. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index cae0aab6d4..1cc6214599 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -25,12 +30,11 @@ "@cordisjs/plugin-timer": "^1.1.2", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-skill-local": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -39,6 +43,7 @@ "@deepseek-ai/dsh-tool-skill": "^0.0.1", "@deepseek-ai/dsh-tool-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -46,12 +51,11 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -60,6 +64,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7" }, "dependencies": { diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts new file mode 100644 index 0000000000..913b5ca2ab --- /dev/null +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-agent-spine-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-agent-spine-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-spine-demo' + +/** Cordis companion plugin name. */ +export const name = 'agent-spine-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/package.json b/packages/examples/cli-demo/package.json index 7b035e2524..1c00a32891 100644 --- a/packages/examples/cli-demo/package.json +++ b/packages/examples/cli-demo/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./bin": { "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" @@ -23,6 +27,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -35,6 +40,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-app-boot": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", @@ -49,6 +55,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/examples/cli-demo/src/invariant.ts b/packages/examples/cli-demo/src/invariant.ts new file mode 100644 index 0000000000..5681362dcc --- /dev/null +++ b/packages/examples/cli-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-cli-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-cli-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-cli-demo' + +/** Cordis companion plugin name. */ +export const name = 'cli-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tsconfig.json b/packages/examples/cli-demo/tsconfig.json index f25b1592ca..c7e3aed914 100644 --- a/packages/examples/cli-demo/tsconfig.json +++ b/packages/examples/cli-demo/tsconfig.json @@ -8,15 +8,38 @@ }, "include": ["src/**/*.ts"], "references": [ - { "path": "../../../vendor/schemastery" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" }, - { "path": "../../core/agent" }, - { "path": "../../core/system-prompt" }, - { "path": "../../core/tools" }, - { "path": "../agent-spine-demo" }, - { "path": "../../session-persistence/session-persistence-jsonl" }, - { "path": "../../ui/app-boot" } + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../agent-spine-demo" + }, + { + "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../ui/app-boot" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/examples/cli-demo/tsdown.config.ts b/packages/examples/cli-demo/tsdown.config.ts index e5b164d46f..646855bea9 100644 --- a/packages/examples/cli-demo/tsdown.config.ts +++ b/packages/examples/cli-demo/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown' /** Builds the plugin and executable entries from declarations emitted by `tsc -b`. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 3b04fcc977..d103e3bd00 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./bin": { "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" @@ -23,6 +27,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -33,9 +38,11 @@ "@deepseek-ai/dsh-app-boot": "workspace:^" }, "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts new file mode 100644 index 0000000000..21f19faf0f --- /dev/null +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-jsonrpc-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-jsonrpc-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc-demo' + +/** Cordis companion plugin name. */ +export const name = 'jsonrpc-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/jsonrpc-demo/tsconfig.json b/packages/examples/jsonrpc-demo/tsconfig.json index 83d6cf5aa2..aba279d405 100644 --- a/packages/examples/jsonrpc-demo/tsconfig.json +++ b/packages/examples/jsonrpc-demo/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../ui/app-boot" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/examples/jsonrpc-demo/tsdown.config.ts b/packages/examples/jsonrpc-demo/tsdown.config.ts index aaa860edd0..a8864a84a9 100644 --- a/packages/examples/jsonrpc-demo/tsdown.config.ts +++ b/packages/examples/jsonrpc-demo/tsdown.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'tsdown' * Build the doc-only module and CLI entry; `tsc -b` supplies declarations. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index 94554e3f8e..69c45bb362 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/stdio-demo/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./bin": { "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" @@ -23,6 +27,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -33,19 +38,20 @@ "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@cordisjs/plugin-logger-console": "^1.0.0", - "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", + "@deepseek-ai/dsh-app-boot": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-stdio": "^0.0.1", - "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" }, @@ -53,20 +59,21 @@ "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-logger-console": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-stdio": "workspace:^", - "@deepseek-ai/dsh-tui": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" } diff --git a/packages/examples/stdio-demo/src/invariant.ts b/packages/examples/stdio-demo/src/invariant.ts new file mode 100644 index 0000000000..5d51c3cd9b --- /dev/null +++ b/packages/examples/stdio-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio-demo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-stdio-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-stdio-demo' + +/** Cordis companion plugin name. */ +export const name = 'stdio-demo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json index fc6711ffb9..265ee5f35b 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/examples/stdio-demo/tsdown.config.ts b/packages/examples/stdio-demo/tsdown.config.ts index 53797cdd79..7e907e8a18 100644 --- a/packages/examples/stdio-demo/tsdown.config.ts +++ b/packages/examples/stdio-demo/tsdown.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown' * matching every package. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index dd80cb4d9c..75a0400afb 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -30,6 +36,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts new file mode 100644 index 0000000000..0c89f2299b --- /dev/null +++ b/packages/fs/fs-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-fs-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-fs-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-fs-local' + +/** Cordis companion plugin name. */ +export const name = 'fs-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs-local/tsconfig.json b/packages/fs/fs-local/tsconfig.json index 0808fd29ca..b249913d43 100644 --- a/packages/fs/fs-local/tsconfig.json +++ b/packages/fs/fs-local/tsconfig.json @@ -6,10 +6,23 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../fs" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../fs" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index e27302e4a6..e74852ef7d 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,10 +28,12 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts new file mode 100644 index 0000000000..11f155d2ec --- /dev/null +++ b/packages/fs/fs-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-fs-policy`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-fs-policy/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-fs-policy' + +/** Cordis companion plugin name. */ +export const name = 'fs-policy-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs-policy/tsconfig.json b/packages/fs/fs-policy/tsconfig.json index fcc1307a36..3f22545107 100644 --- a/packages/fs/fs-policy/tsconfig.json +++ b/packages/fs/fs-policy/tsconfig.json @@ -6,9 +6,20 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../llm/llm" }, - { "path": "../fs" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../fs" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index f1efde152a..3f15e0762a 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts new file mode 100644 index 0000000000..55895b4b24 --- /dev/null +++ b/packages/fs/fs/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-fs`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-fs/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-fs' + +/** Cordis companion plugin name. */ +export const name = 'fs-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs/tsconfig.json b/packages/fs/fs/tsconfig.json index a352aea65a..5fb6fec5a3 100644 --- a/packages/fs/fs/tsconfig.json +++ b/packages/fs/fs/tsconfig.json @@ -6,9 +6,20 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../util/brand" }, - { "path": "../../llm/llm" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 002d54569a..45de8f5e4a 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -26,6 +31,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -38,6 +44,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts new file mode 100644 index 0000000000..f0055b611a --- /dev/null +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-fs-search`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-fs-search/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs-search' + +/** Cordis companion plugin name. */ +export const name = 'tool-fs-search-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs-search/tsconfig.json b/packages/fs/tool-fs-search/tsconfig.json index 9241aca15b..ad0c703117 100644 --- a/packages/fs/tool-fs-search/tsconfig.json +++ b/packages/fs/tool-fs-search/tsconfig.json @@ -6,15 +6,38 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../util/retention" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" }, - { "path": "../../core/tools" }, - { "path": "../../core/system-prompt" }, - { "path": "../../bash/bash" }, - { "path": "../../spill/spill" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/retention" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../spill/spill" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index f9ef3136bf..51586836c9 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -27,6 +32,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -37,9 +43,10 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts new file mode 100644 index 0000000000..7a683d978f --- /dev/null +++ b/packages/fs/tool-fs/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-fs`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-fs/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs' + +/** Cordis companion plugin name. */ +export const name = 'tool-fs-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index f0133b1d2b..43c91b2dd6 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../core/tools" }, - { "path": "../../core/system-prompt" }, - { "path": "../fs" }, - { "path": "../fs-policy" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../fs" + }, + { + "path": "../fs-policy" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 92d49c548f..c892ca99e4 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -26,6 +31,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -33,6 +39,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts new file mode 100644 index 0000000000..05c4df1a66 --- /dev/null +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-repeat-tool-guard`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-repeat-tool-guard/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-repeat-tool-guard' + +/** Cordis companion plugin name. */ +export const name = 'repeat-tool-guard-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/guard/repeat-tool-guard/tsconfig.json b/packages/guard/repeat-tool-guard/tsconfig.json index 66439bcd5f..9ca11b7119 100644 --- a/packages/guard/repeat-tool-guard/tsconfig.json +++ b/packages/guard/repeat-tool-guard/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 201c744219..f357278db3 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts new file mode 100644 index 0000000000..9893493644 --- /dev/null +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-hook-protocol`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-hook-protocol/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-hook-protocol' + +/** Cordis companion plugin name. */ +export const name = 'hook-protocol-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hook-protocol/tsconfig.json b/packages/hooks/hook-protocol/tsconfig.json index dc4f8d9e16..220748cb0f 100644 --- a/packages/hooks/hook-protocol/tsconfig.json +++ b/packages/hooks/hook-protocol/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index c6870a6471..34696ab625 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -27,6 +32,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-hook-protocol": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -41,6 +47,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts new file mode 100644 index 0000000000..5c6002f7f5 --- /dev/null +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-hooks-claude`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-hooks-claude/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude' + +/** Cordis companion plugin name. */ +export const name = 'hooks-claude-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 65dd29d146..953b4befd0 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -10,7 +10,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import { SubagentRunId } from '@deepseek-ai/dsh-subagent' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -25,6 +26,10 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent const dirs: string[] = [] afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) +function subagentCarrier(ctx: Context) { + return scopeTarget(ctx as unknown as SubagentService, undefined) +} + /** Write a hooks.json + named executable scripts into a fresh temp dir. */ function writeConfig(hooks: unknown, scripts: Record = {}): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) @@ -290,8 +295,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's // child lookup yields undefined and it simply runs the hook. - ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) - ctx.emit('subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) // Both hooks run async (detached .then); poll for their marker files rather // than a fixed sleep that flakes under load. @@ -326,7 +331,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index ec7d18b4c4..4b8036f3a5 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -10,7 +10,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import { SubagentRunId } from '@deepseek-ai/dsh-subagent' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -20,6 +21,10 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent const dirs: string[] = [] afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) +function subagentCarrier(ctx: Context) { + return scopeTarget(ctx as unknown as SubagentService, undefined) +} + function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d } function sh(d: string, name: string, body: string): string { const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p @@ -233,7 +238,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const injected: string[] = [] const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') }) @@ -249,7 +254,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const warn = vi.fn(); ctx.logger.warn = warn as never const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) @@ -293,7 +298,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) + ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) }) @@ -693,7 +698,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // Register a live child on its own session cwd; emit subagent/end with its id. const { SessionId } = await import('@deepseek-ai/dsh-session') const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - ctx.emit('subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) + ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir diff --git a/packages/hooks/hooks-claude/tsconfig.json b/packages/hooks/hooks-claude/tsconfig.json index 07c88610f9..445d0f68b1 100644 --- a/packages/hooks/hooks-claude/tsconfig.json +++ b/packages/hooks/hooks-claude/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../bash/bash" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 8c8686c539..5d583baafe 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -27,6 +32,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-hook-protocol": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -40,6 +46,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts new file mode 100644 index 0000000000..1b8f03a057 --- /dev/null +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-hooks-codex`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-hooks-codex/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-codex' + +/** Cordis companion plugin name. */ +export const name = 'hooks-codex-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-codex/tsconfig.json b/packages/hooks/hooks-codex/tsconfig.json index ae3c91e9dd..3bd9bd91e5 100644 --- a/packages/hooks/hooks-codex/tsconfig.json +++ b/packages/hooks/hooks-codex/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../bash/bash" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 1461ad0f44..af8ca45085 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts new file mode 100644 index 0000000000..c3e6b5e153 --- /dev/null +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-deepseek`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-llm-deepseek/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-deepseek' + +/** Cordis companion plugin name. */ +export const name = 'llm-deepseek-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index e9de391ba1..d145ddb6ee 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index c922467deb..6901441490 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts new file mode 100644 index 0000000000..4ada7a1a51 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-pi-ai`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-llm-pi-ai/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-pi-ai' + +/** Cordis companion plugin name. */ +export const name = 'llm-pi-ai-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index e9de391ba1..d145ddb6ee 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index ab11c8574f..9ef3e3323e 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,10 +28,12 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts new file mode 100644 index 0000000000..a8a56ce245 --- /dev/null +++ b/packages/llm/llm/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-llm`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-llm/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm' + +/** Cordis companion plugin name. */ +export const name = 'llm-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 342f636170..5bc7a9fcf5 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../util/brand" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 13fa4e3cdc..dadd5e8f8d 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts new file mode 100644 index 0000000000..00ceb567be --- /dev/null +++ b/packages/llm/token-meter/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-token-meter`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-token-meter/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-token-meter' + +/** Cordis companion plugin name. */ +export const name = 'token-meter-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e7ddca6696..d49c96fdb0 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -631,19 +631,24 @@ describe('malformed replay and listener lifecycle', () => { }) const firstFiber = await ctx.plugin(TokenMeterService) activeMeter = ctx.tokenMeter - const session = ctx.sessions.create(SessionId('listener-order')) + const session = ctx.sessions.create(SessionId('listener-order'), { seed: [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }] }) activeMeter.measure(session) session.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) - expect(revisions).toEqual([1]) - expect(activeMeter.measure(session).logRevision).toBe(1) + expect(revisions).toEqual([2]) + expect(activeMeter.measure(session).logRevision).toBe(2) await firstFiber.dispose() const secondFiber = await ctx.plugin(TokenMeterService) activeMeter = ctx.tokenMeter - expect(activeMeter.measure(session).logRevision).toBe(1) + expect(activeMeter.measure(session).logRevision).toBe(2) await secondFiber.dispose() }) }) diff --git a/packages/llm/token-meter/tsconfig.json b/packages/llm/token-meter/tsconfig.json index 5e1604e02f..481fad6e15 100644 --- a/packages/llm/token-meter/tsconfig.json +++ b/packages/llm/token-meter/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 6b8f145108..cb3b51e1aa 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -11,19 +11,25 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -31,8 +37,9 @@ "schemastery": "^3.18.0" }, "devDependencies": { - "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", "cordis": "^4.0.0-rc.7", diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts new file mode 100644 index 0000000000..d9e75e9955 --- /dev/null +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-mcp-client`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-mcp-client/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-mcp-client' + +/** Cordis companion plugin name. */ +export const name = 'mcp-client-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index e9c9266415..668ee2c3cb 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -6,10 +6,23 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../core/tools" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 9dd90a3e9a..6750b92577 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -31,6 +37,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts new file mode 100644 index 0000000000..3582962f94 --- /dev/null +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-sandbox-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-sandbox-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-local' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json index c756b6af69..608f0e9568 100644 --- a/packages/sandbox/sandbox-local/tsconfig.json +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../sandbox" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 50c5b443ba..84266499b3 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -11,21 +11,28 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts new file mode 100644 index 0000000000..b220f11717 --- /dev/null +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-sandbox`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-sandbox/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json index 9f687793d7..af4de1c016 100644 --- a/packages/sandbox/sandbox/tsconfig.json +++ b/packages/sandbox/sandbox/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/sdk/create-sdk/package.json b/packages/sdk/create-sdk/package.json index e23415f8dc..27102154fc 100644 --- a/packages/sdk/create-sdk/package.json +++ b/packages/sdk/create-sdk/package.json @@ -13,10 +13,15 @@ ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" } }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/assets", "lib/types/**/*.d.ts", @@ -29,9 +34,11 @@ "commander": "^15.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts new file mode 100644 index 0000000000..87a697e438 --- /dev/null +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/create-sdk`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/create-sdk/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/create-sdk' + +/** Cordis companion plugin name. */ +export const name = 'create-sdk-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sdk/create-sdk/tsconfig.json b/packages/sdk/create-sdk/tsconfig.json index f77f711880..e2ed951fa4 100644 --- a/packages/sdk/create-sdk/tsconfig.json +++ b/packages/sdk/create-sdk/tsconfig.json @@ -6,7 +6,14 @@ }, "include": ["src"], "references": [ - { "path": "../helper" }, - { "path": "../../../vendor/cordis" } + { + "path": "../helper" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/sdk/create-sdk/tsdown.config.ts b/packages/sdk/create-sdk/tsdown.config.ts index cdf9ef6d4f..08d522590e 100644 --- a/packages/sdk/create-sdk/tsdown.config.ts +++ b/packages/sdk/create-sdk/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown' /** Bundle the library and create bin, then mirror package-owned terminal templates. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json index 8d95dafe66..1d9c6a2be2 100644 --- a/packages/sdk/helper/package.json +++ b/packages/sdk/helper/package.json @@ -10,10 +10,15 @@ ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" } }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/assets", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -29,12 +34,14 @@ }, "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-hooks-claude": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts new file mode 100644 index 0000000000..63a6fc2055 --- /dev/null +++ b/packages/sdk/helper/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-helper`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-helper/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-helper' + +/** Cordis companion plugin name. */ +export const name = 'helper-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sdk/helper/tsconfig.json b/packages/sdk/helper/tsconfig.json index e4a8604575..18e79898c7 100644 --- a/packages/sdk/helper/tsconfig.json +++ b/packages/sdk/helper/tsconfig.json @@ -6,14 +6,35 @@ }, "include": ["src"], "references": [ - { "path": "../../util/brand" }, - { "path": "../../compact/compact-basic" }, - { "path": "../../hooks/hooks-claude" }, - { "path": "../../hooks/hooks-codex" }, - { "path": "../../session-persistence/session-persistence-jsonl" }, - { "path": "../../session-persistence/session-persistence-sqlite" }, - { "path": "../../subagent/tool-subagent" }, - { "path": "../../web/tool-web" }, - { "path": "../../../vendor/cordis" } + { + "path": "../../util/brand" + }, + { + "path": "../../compact/compact-basic" + }, + { + "path": "../../hooks/hooks-claude" + }, + { + "path": "../../hooks/hooks-codex" + }, + { + "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../session-persistence/session-persistence-sqlite" + }, + { + "path": "../../subagent/tool-subagent" + }, + { + "path": "../../web/tool-web" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/sdk/helper/tsdown.config.ts b/packages/sdk/helper/tsdown.config.ts index cb8b9fafef..b8ba9cb652 100644 --- a/packages/sdk/helper/tsdown.config.ts +++ b/packages/sdk/helper/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown' /** Bundle helper runtime and mirror template assets beside the bundle. */ export default defineConfig({ - entry: ['lib/types/index.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/sdk/scripts/package.json b/packages/sdk/scripts/package.json index ba441a528c..3f607f7849 100644 --- a/packages/sdk/scripts/package.json +++ b/packages/sdk/scripts/package.json @@ -14,6 +14,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./dev/tsdown-config": { "types": "./lib/types/dev/tsdown-config.d.ts", "default": "./lib/dev/tsdown-config.js" @@ -21,6 +25,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/bin.js", "lib/dev/tsdown-config.js", "lib/local-plugin-loader-hooks.js", @@ -37,16 +42,22 @@ }, "peerDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", "tsdown": "^0.22.2", "tsx": "^4.22.4" }, "peerDependenciesMeta": { - "tsdown": { "optional": true }, - "tsx": { "optional": true } + "tsdown": { + "optional": true + }, + "tsx": { + "optional": true + } }, "devDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7", "tsdown": "^0.22.2", "tsx": "^4.22.4" diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts new file mode 100644 index 0000000000..fd0a0ef55b --- /dev/null +++ b/packages/sdk/scripts/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-scripts`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-scripts/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' + +/** Cordis companion plugin name. */ +export const name = 'scripts-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sdk/scripts/tsconfig.json b/packages/sdk/scripts/tsconfig.json index 848de9a314..6ada45e1ac 100644 --- a/packages/sdk/scripts/tsconfig.json +++ b/packages/sdk/scripts/tsconfig.json @@ -6,8 +6,17 @@ }, "include": ["src"], "references": [ - { "path": "../helper" }, - { "path": "../../ui/app-boot" }, - { "path": "../../../vendor/cordis" } + { + "path": "../helper" + }, + { + "path": "../../ui/app-boot" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/sdk/scripts/tsdown.config.ts b/packages/sdk/scripts/tsdown.config.ts index 2218faa7be..14bb59b8bc 100644 --- a/packages/sdk/scripts/tsdown.config.ts +++ b/packages/sdk/scripts/tsdown.config.ts @@ -7,6 +7,10 @@ export default defineConfig([ fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }], }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, { entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, diff --git a/packages/session-persistence/session-persistence-jsonl/package.json b/packages/session-persistence/session-persistence-jsonl/package.json index ddb9f2af4d..bc5c6d2c4c 100644 --- a/packages/session-persistence/session-persistence-jsonl/package.json +++ b/packages/session-persistence/session-persistence-jsonl/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts new file mode 100644 index 0000000000..12c65db1c4 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence-jsonl`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-session-persistence-jsonl/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-jsonl' + +/** Cordis companion plugin name. */ +export const name = 'session-persistence-jsonl-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index e7dc469132..710a0ec9f1 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -21,17 +21,15 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader { return header } -async function expectParallelFlushError(promise: Promise, message: RegExp): Promise { +async function expectFlushError(promise: Promise, message: RegExp): Promise { try { await promise } catch (error) { - expect(error).toBeInstanceOf(AggregateError) - const [cause] = (error as AggregateError).errors as unknown[] - expect(cause).toBeInstanceOf(Error) - expect((cause as Error).message).toMatch(message) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(message) return } - throw new Error('expected parallel flush to reject') + throw new Error('expected flush to reject') } async function freshRoot(): Promise { @@ -250,7 +248,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { appendClosedTurn(source) const child = ctx.sessions.fork(source, undefined, SessionId('persist-child')) - await ctx.parallel('session/flush', child) + await ctx.sessions.flush(child) const loaded = await ctx.sessionPersistence.load(child.id) expect(loaded.events).toEqual(source.events) @@ -429,12 +427,14 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => const a = ctx.sessions.create(SessionId('sa')) const b = ctx.sessions.create(SessionId('sb')) + a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) b.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', a) - await ctx.parallel('session/flush', b) + await ctx.sessions.flush(a) + await ctx.sessions.flush(b) const la = await ctx.sessionPersistence.load(SessionId('sa')) const lb = await ctx.sessionPersistence.load(SessionId('sb')) @@ -607,7 +607,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { }, { inject: ['sessions'] })) // Drain A, then dispose ITS fiber (the live session A is gone) while the // backend stays loaded. - for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s) + for (const s of ctx.sessions.list()) await ctx.sessions.flush(s) await sessFiberA.dispose() // A new Session object reuses the id. Object-keyed initialization must run independently, @@ -675,7 +675,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }, { inject: ['sessions'] })) - for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s) + for (const s of ctx.sessions.list()) await ctx.sessions.flush(s) await firstFiber.dispose() let second!: Session @@ -783,18 +783,19 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) const session = ctx2.sessions.create(SessionId('flush-fail')) // A full turn lands in the write-behind buffer. + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // Make the durable materialize fail on the next flush. const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise } const origMat = backend.materialize.bind(backend) backend.materialize = () => Promise.reject(new Error('disk full')) - await expectParallelFlushError(ctx2.parallel('session/flush', session), /disk full/) + await expectFlushError(ctx2.sessions.flush(session), /disk full/) // The events are STILL buffered (not silently dropped): a retry persists them. backend.materialize = origMat - await ctx2.parallel('session/flush', session) + await ctx2.sessions.flush(session) const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1]) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2]) await ctx2.fiber.dispose() }) diff --git a/packages/session-persistence/session-persistence-jsonl/tsconfig.json b/packages/session-persistence/session-persistence-jsonl/tsconfig.json index 23970f5a57..044156938b 100644 --- a/packages/session-persistence/session-persistence-jsonl/tsconfig.json +++ b/packages/session-persistence/session-persistence-jsonl/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/session-persistence/session-persistence-sqlite/package.json b/packages/session-persistence/session-persistence-sqlite/package.json index f367b737b3..f65bdebd16 100644 --- a/packages/session-persistence/session-persistence-sqlite/package.json +++ b/packages/session-persistence/session-persistence-sqlite/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts new file mode 100644 index 0000000000..a9b04cf5f5 --- /dev/null +++ b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence-sqlite`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-session-persistence-sqlite/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite' + +/** Cordis companion plugin name. */ +export const name = 'session-persistence-sqlite-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index f26edfa54d..ee599987bb 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -14,17 +14,15 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) -async function expectParallelFlushError(promise: Promise, message: RegExp): Promise { +async function expectFlushError(promise: Promise, message: RegExp): Promise { try { await promise } catch (error) { - expect(error).toBeInstanceOf(AggregateError) - const [cause] = (error as AggregateError).errors as unknown[] - expect(cause).toBeInstanceOf(Error) - expect((cause as Error).message).toMatch(message) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(message) return } - throw new Error('expected parallel flush to reject') + throw new Error('expected flush to reject') } async function freshDbPath(): Promise { @@ -502,7 +500,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { const b1 = await backend(path) const s1 = b1.ctx.sessions.create(SessionId('hmr-collide')) appendLog(s1, oneTurnLog()) - await b1.ctx.parallel('session/flush', s1) + await b1.ctx.sessions.flush(s1) await b1.dispose() // A fresh context with an UNRELATED live session reusing the id meets a @@ -513,9 +511,9 @@ describe('SessionPersistenceSqlite: edge cases', () => { await ctx.plugin(Object.assign((inner: Context) => { session = inner.sessions.create(SessionId('hmr-collide')) }, { inject: ['sessions'] })) - session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) await ctx.plugin(SessionPersistenceSqlite, { path }) - await expectParallelFlushError(ctx.parallel('session/flush', session), /id collision/) + await expectFlushError(ctx.sessions.flush(session), /id collision/) await ctx.fiber.dispose() }) }) @@ -567,18 +565,20 @@ describe('surface field round-trip', () => { const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) const session = ctx.sessions.create(SessionId('roundtrip-surface')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [0] }) + session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] }) + session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface')) - expect(loaded.events).toHaveLength(4) - const um = loaded.events[1]! + expect(loaded.events).toHaveLength(6) + const um = loaded.events[2]! expect((um as SurfaceEvent).surfaceOp).toBe('append') expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined() - const am = loaded.events[2]! + const am = loaded.events[3]! expect((am as SurfaceEvent).surfaceOp).toBe('append') - expect((am as SurfaceEvent).sourceEventSeqs).toEqual([0]) + expect((am as SurfaceEvent).sourceEventSeqs).toEqual([2]) await fiber.dispose() }) @@ -590,7 +590,7 @@ describe('surface field round-trip', () => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('steering/message', { turn: 1, content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq')) expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append') expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined() diff --git a/packages/session-persistence/session-persistence-sqlite/tsconfig.json b/packages/session-persistence/session-persistence-sqlite/tsconfig.json index 23970f5a57..044156938b 100644 --- a/packages/session-persistence/session-persistence-sqlite/tsconfig.json +++ b/packages/session-persistence/session-persistence-sqlite/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/session-persistence/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json index 91eef09007..a503b146ae 100644 --- a/packages/session-persistence/session-persistence/package.json +++ b/packages/session-persistence/session-persistence/package.json @@ -11,21 +11,29 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/session-persistence/session-persistence/src/invariant.ts b/packages/session-persistence/session-persistence/src/invariant.ts new file mode 100644 index 0000000000..cc7cc8fa2c --- /dev/null +++ b/packages/session-persistence/session-persistence/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-session-persistence/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence' + +/** Cordis companion plugin name. */ +export const name = 'session-persistence-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index defcaefb6d..47867c2f56 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { meta, oneTurnLog, appendLog } from './contract.ts' @@ -76,7 +77,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } }) send(session, oneTurnLog()) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('live')) expect(loaded.events).toHaveLength(6) @@ -96,7 +97,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const session = ctx.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } }) send(session, oneTurnLog()) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('forked-child')) expect(loaded.meta.seedLength).toBe(3) @@ -111,17 +112,17 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) 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) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('mutate')) - const first = loaded.events[0] - expect(first?.type === 'user/message' && (first.data.content[0] as { text: string }).text).toBe('original') + const message = loaded.events.find(event => event.type === 'user/message') + expect(message?.type === 'user/message' && (message.data.content[0] as { text: string }).text).toBe('original') } finally { await fiber.dispose() await fix.cleanup() @@ -166,7 +167,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const loaded = await ctx.sessionPersistence.load(SessionId('forked')) expect(loaded.events).toEqual(seed) // A flush with no NEW events must not double-write. - await ctx.parallel('session/flush', forked) + await ctx.sessions.flush(forked) const reloaded = await ctx.sessionPersistence.load(SessionId('forked')) expect(reloaded.events).toEqual(seed) } finally { @@ -182,7 +183,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) - await first.ctx.parallel('session/flush', s1) + await first.ctx.sessions.flush(s1) } finally { await first.fiber.dispose() } @@ -194,7 +195,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await second.ctx.sessions.flush(s2) // let onCreated adopt s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await second.ctx.parallel('session/flush', s2) + await second.ctx.sessions.flush(s2) const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) @@ -212,13 +213,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) // A session exists BEFORE the persistence plugin is applied. const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const fiber = await fix.mount(ctx) try { // The plugin seeded it on apply; a subsequent flush persists its events. - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing')) expect(loaded.events.length).toBeGreaterThanOrEqual(2) } finally { @@ -233,6 +235,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(SessionStore) const fiber = await fix.mount(ctx) const session = await liveSessionInFiber(ctx, 'drain', WORK) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // No explicit flush — dispose must drain. @@ -261,7 +264,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) // Hot-reload: dispose instance 1, mount instance 2 over the same storage while the // session stays live. The new instance has no coordinator state but must adopt the @@ -271,7 +274,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow() + await expect(ctx.sessions.flush(session)).resolves.not.toThrow() const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt')) expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) @@ -291,7 +294,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const backend1 = await fix.mount(ctx) 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) + await ctx.sessions.flush(session) // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT // flushing turn 2: it is now ONLY in the live session's events; the new @@ -303,7 +306,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the // live suffix (turn 2) carried in the session's events. await fix.mount(ctx) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3]) expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) @@ -322,7 +325,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const first = await fix.mount(ctx) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) // Crash-tail a torn fragment past the (open) committed turn, then reload. await first.dispose() @@ -332,7 +335,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // end. Adoption must truncate the torn tail but NOT synthesize closers. session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open')) expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) @@ -352,7 +355,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) - await first.ctx.parallel('session/flush', s1) + await first.ctx.sessions.flush(s1) } finally { await first.fiber.dispose() } @@ -392,7 +395,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined() reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', reuse) + await ctx.sessions.flush(reuse) const loaded = await ctx.sessionPersistence.load(SessionId('abandoned')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1]) } finally { @@ -438,14 +441,15 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) // Re-emit session/created for the SAME live session (idempotent initFor). - ctx.emit('session/created', session) - await ctx.parallel('session/flush', session) + ctx.emit(scopeTarget(session, undefined), 'session/created', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('idem')) - expect(loaded.events).toHaveLength(2) // not doubled + expect(loaded.events).toHaveLength(3) // not doubled } finally { await fiber.dispose() await fix.cleanup() @@ -690,11 +694,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // async onCreated init has necessarily set state (exercises the // state-undefined cursor path). const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate')) - expect(loaded.events).toHaveLength(2) + expect(loaded.events).toHaveLength(3) } finally { await fiber.dispose() await fix.cleanup() diff --git a/packages/session-persistence/session-persistence/tsconfig.json b/packages/session-persistence/session-persistence/tsconfig.json index e817086a6a..cbd74a19e7 100644 --- a/packages/session-persistence/session-persistence/tsconfig.json +++ b/packages/session-persistence/session-persistence/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index ae2767598a..040697391f 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -36,6 +42,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts new file mode 100644 index 0000000000..91bcee721e --- /dev/null +++ b/packages/session-query/session-query/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-session-query`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-session-query/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-query' + +/** Cordis companion plugin name. */ +export const name = 'session-query-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index f532edf168..658eb709d9 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -101,6 +101,8 @@ describe('session-query exact reads', () => { it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('surface')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) const first = session.append( 'user/message', { content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }, @@ -117,13 +119,14 @@ describe('session-query exact reads', () => { { surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] }, ) - expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface)) + expect((await ctx.sessionQuery.listEvents(session.id)).slice(2).map(record => record.surface)) .toEqual(['shadowed', 'log-only', 'current']) }) it('returns a bounded detached raw-event window and validates the request', async () => { const ctx = await liveContext({ readWindowMax: 1 }) const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) for (const text of ['one', 'two', 'three']) { session.append( 'user/message', @@ -132,14 +135,14 @@ describe('session-query exact reads', () => { ) } - const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 1, before: 1, after: 1 }) - expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([0, 2, 1]) + const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 2, before: 1, after: 1 }) + expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([1, 3, 2]) expect(result.session).toEqual(session.header) Object.assign(result.session, { createdAt: -1 }) if (result.events[0]?.type !== 'user/message') throw new Error('expected user message') result.events[0].data.content = [] expect(session.header.createdAt).not.toBe(-1) - expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1) + expect(session.events[1]?.type === 'user/message' && session.events[1].data.content).toHaveLength(1) await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 })) .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) @@ -161,6 +164,7 @@ describe('session-query exact reads', () => { ]) const ctx = await liveContext() const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } }) + live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) live.append( 'user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, @@ -170,7 +174,7 @@ describe('session-query exact reads', () => { expect((await ctx.sessionQuery.listSessions()).map(record => [record.header.id, record.live, record.persisted])) .toEqual([[shared.id, true, true], [durable.id, false, true]]) - const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 }) + const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 1 }) expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0]) .toMatchObject({ text: 'live' }) await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 })) @@ -189,6 +193,7 @@ describe('session-query exact reads', () => { TestPersistence.reset() const ctx = await liveContext() const live = ctx.sessions.create(SessionId('live')) + live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) live.append( 'user/message', { content: [{ type: 'text', text: 'available' }], source: { kind: 'user' } }, @@ -198,8 +203,8 @@ describe('session-query exact reads', () => { TestPersistence.listFailure = new Error('list unavailable') TestPersistence.loadFailure = new Error('load unavailable') - await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(1) - await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 0 })).resolves.toMatchObject({ target: { seq: 0 } }) + await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2) + await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } }) await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) @@ -228,19 +233,8 @@ describe('session-query exact reads', () => { .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) }) - it('turns malformed surfaces and direct invalid config into typed errors', async () => { + it('turns persisted malformed surfaces and direct invalid config into typed errors', async () => { const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('bad-surface')) - ;(session as unknown as { log: SessionEvent[] }).log.push({ - type: 'assistant/message', - seq: 0, - time: 1, - data: { turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' } }, - surfaceOp: { op: 'replace', start: 9, end: 9 }, - }) - await expect(ctx.sessionQuery.listEvents(session.id)) - .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) - const persisted = header('bad-persisted-surface') TestPersistence.reset([{ meta: persisted, diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 03e1adad89..ee8b1d833f 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -89,6 +89,8 @@ function expectCode(code: SessionQueryErrorCode): Error { } function appendTraceEvents(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/chunk', { turn: 1, step: 1, @@ -97,22 +99,24 @@ function appendTraceEvents(session: Session): void { session.append( 'user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, - { surfaceOp: 'append', sourceEventSeqs: [0] }, + { surfaceOp: 'append', sourceEventSeqs: [2] }, ) session.append( 'assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] }, - { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1, 0] }, + { surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] }, ) session.append( 'context/message', { content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }, { surfaceOp: 'append' }, ) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) session.append( 'assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] }, - { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [0, 2] }, + { surfaceOp: { op: 'replace', start: 4, end: 4 }, sourceEventSeqs: [2, 4] }, ) } @@ -235,40 +239,40 @@ describe('session event tracing', () => { const session = ctx.sessions.create(SessionId('trace')) appendTraceEvents(session) - const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 1 }) + const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 3 }) expect(original.target).toMatchObject({ sessionId: session.id, - seq: 1, + seq: 3, type: 'user/message', surface: 'shadowed', }) expect(original).toMatchObject({ - replacedBy: 2, - replacementChain: [2, 4], + replacedBy: 4, + replacementChain: [4, 8], replacedEventSeqs: [], - sourceEventSeqs: [0], - derivedEventSeqs: [2], + sourceEventSeqs: [2], + derivedEventSeqs: [4], }) - await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })) .resolves.toMatchObject({ - replacedBy: 4, - replacementChain: [4], - replacedEventSeqs: [1], - sourceEventSeqs: [1, 0], - derivedEventSeqs: [4], + replacedBy: 8, + replacementChain: [8], + replacedEventSeqs: [3], + sourceEventSeqs: [3, 2], + derivedEventSeqs: [8], }) - await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 0 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })) .resolves.toMatchObject({ target: { surface: 'log-only' }, replacementChain: [], sourceEventSeqs: [], - derivedEventSeqs: [1, 2, 4], + derivedEventSeqs: [3, 4, 8], }) - await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 8 })) .resolves.toMatchObject({ replacementChain: [], - replacedEventSeqs: [2], - sourceEventSeqs: [0, 2], + replacedEventSeqs: [4], + sourceEventSeqs: [2, 4], derivedEventSeqs: [], }) }) @@ -278,18 +282,18 @@ describe('session event tracing', () => { const session = ctx.sessions.create(SessionId('detached')) appendTraceEvents(session) - const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }) + const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }) first.target.time = -1 first.replacementChain.push(99) first.replacedEventSeqs.push(99) first.sourceEventSeqs.push(99) first.derivedEventSeqs.push(99) - const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }) + const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }) expect(repeated.target.time).not.toBe(-1) - expect(repeated.replacementChain).toEqual([4]) - expect(repeated.replacedEventSeqs).toEqual([1]) - expect(repeated.sourceEventSeqs).toEqual([1, 0]) - expect(repeated.derivedEventSeqs).toEqual([4]) + expect(repeated.replacementChain).toEqual([8]) + expect(repeated.replacedEventSeqs).toEqual([3]) + expect(repeated.sourceEventSeqs).toEqual([3, 2]) + expect(repeated.derivedEventSeqs).toEqual([8]) }) it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => { @@ -303,6 +307,7 @@ describe('session event tracing', () => { expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) + live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) live.append( 'context/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } }, @@ -310,7 +315,7 @@ describe('session event tracing', () => { ) TracePersistence.listFailure = new Error('list unavailable') TracePersistence.loadFailure = new Error('load unavailable') - await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 })) .resolves.toMatchObject({ target: { type: 'context/message' } }) expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index 7153dae8bb..d7205907e1 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index d490438c51..be7c7baae2 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -34,6 +40,7 @@ "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/skill/skill-local/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts new file mode 100644 index 0000000000..475d02bb8f --- /dev/null +++ b/packages/skill/skill-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-skill-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-skill-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-skill-local' + +/** Cordis companion plugin name. */ +export const name = 'skill-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/skill-local/tsconfig.json b/packages/skill/skill-local/tsconfig.json index f51147abce..d471e269d0 100644 --- a/packages/skill/skill-local/tsconfig.json +++ b/packages/skill/skill-local/tsconfig.json @@ -6,11 +6,26 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../util/home" }, - { "path": "../../fs/fs" }, - { "path": "../skill" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/home" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../skill" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index c025de6ee9..3148a3f577 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -11,23 +11,30 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts new file mode 100644 index 0000000000..c1d4091bb2 --- /dev/null +++ b/packages/skill/skill/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-skill`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-skill/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-skill' + +/** Cordis companion plugin name. */ +export const name = 'skill-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/skill/tsconfig.json b/packages/skill/skill/tsconfig.json index 1b1855dcc4..e882ed2d72 100644 --- a/packages/skill/skill/tsconfig.json +++ b/packages/skill/skill/tsconfig.json @@ -6,8 +6,17 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 3d6ddc6b7e..47421bf19c 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -33,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts new file mode 100644 index 0000000000..abf4ba3961 --- /dev/null +++ b/packages/skill/tool-skill/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-skill`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-skill/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-skill' + +/** Cordis companion plugin name. */ +export const name = 'tool-skill-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/tool-skill/tsconfig.json b/packages/skill/tool-skill/tsconfig.json index 52ebb8bf9d..fed1ffa5f5 100644 --- a/packages/skill/tool-skill/tsconfig.json +++ b/packages/skill/tool-skill/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../core/scope" }, - { "path": "../../llm/llm" }, - { "path": "../../core/agent" }, - { "path": "../skill" }, - { "path": "../../core/tools" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/agent" + }, + { + "path": "../skill" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index a75c4bfc0b..25a20db1d5 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-spill": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -30,6 +36,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts new file mode 100644 index 0000000000..d638ffa2a9 --- /dev/null +++ b/packages/spill/spill-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-spill-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-spill-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-spill-local' + +/** Cordis companion plugin name. */ +export const name = 'spill-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill-local/tsconfig.json b/packages/spill/spill-local/tsconfig.json index 8e818212f5..0cb209d8d0 100644 --- a/packages/spill/spill-local/tsconfig.json +++ b/packages/spill/spill-local/tsconfig.json @@ -6,9 +6,20 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../spill" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../spill" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 9c28ea5382..dac7d4311d 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -34,6 +40,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts new file mode 100644 index 0000000000..d4aa544ecd --- /dev/null +++ b/packages/spill/spill-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-spill-policy`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-spill-policy/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-spill-policy' + +/** Cordis companion plugin name. */ +export const name = 'spill-policy-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill-policy/tsconfig.json b/packages/spill/spill-policy/tsconfig.json index 6a81ab2f3c..71f19d381c 100644 --- a/packages/spill/spill-policy/tsconfig.json +++ b/packages/spill/spill-policy/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../util/retention" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" }, - { "path": "../spill" }, - { "path": "../../core/tools" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/retention" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../spill" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 3103c9cd11..c66306ff0a 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,12 +28,14 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts new file mode 100644 index 0000000000..714e43f3a6 --- /dev/null +++ b/packages/spill/spill/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-spill`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-spill/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-spill' + +/** Cordis companion plugin name. */ +export const name = 'spill-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill/tsconfig.json b/packages/spill/spill/tsconfig.json index 0c2fd5c57f..30d0d29f0f 100644 --- a/packages/spill/spill/tsconfig.json +++ b/packages/spill/spill/tsconfig.json @@ -6,10 +6,23 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../util/brand" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index fa16edcf60..2564afa8da 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", @@ -34,13 +40,14 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts new file mode 100644 index 0000000000..a5828fd4c1 --- /dev/null +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-acp`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-acp/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-acp' + +/** Cordis companion plugin name. */ +export const name = 'subagent-acp-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index 5aa28528ac..175eb78e2f 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../support/loader-smoke" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 63f548d217..aa93b83e03 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", @@ -32,6 +38,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -41,7 +48,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts new file mode 100644 index 0000000000..c6903fd82d --- /dev/null +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-fork`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-fork/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork' + +/** Cordis companion plugin name. */ +export const name = 'subagent-fork-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-fork/tsconfig.json b/packages/subagent/subagent-fork/tsconfig.json index bac12550af..a07a2319ef 100644 --- a/packages/subagent/subagent-fork/tsconfig.json +++ b/packages/subagent/subagent-fork/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../subagent-inprocess" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index b9397e300c..69b573ecdd 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts new file mode 100644 index 0000000000..0eac204104 --- /dev/null +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-inprocess`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-inprocess/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' + +/** Cordis companion plugin name. */ +export const name = 'subagent-inprocess-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index 7b7a015cc9..02fd8e53d0 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 1a986e69aa..f429025a5d 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -42,7 +49,6 @@ "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts new file mode 100644 index 0000000000..d179a2b72a --- /dev/null +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-spawn`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-spawn/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-spawn' + +/** Cordis companion plugin name. */ +export const name = 'subagent-spawn-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-spawn/tsconfig.json b/packages/subagent/subagent-spawn/tsconfig.json index 219bf2a0c9..ee9ab096c2 100644 --- a/packages/subagent/subagent-spawn/tsconfig.json +++ b/packages/subagent/subagent-spawn/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../subagent-inprocess" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/subagent-subprocess/package.json b/packages/subagent/subagent-subprocess/package.json index 5f17459276..bd573b3c0c 100644 --- a/packages/subagent/subagent-subprocess/package.json +++ b/packages/subagent/subagent-subprocess/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts new file mode 100644 index 0000000000..22dac32814 --- /dev/null +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-subprocess`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent-subprocess/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' + +/** Cordis companion plugin name. */ +export const name = 'subagent-subprocess-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-subprocess/tsconfig.json b/packages/subagent/subagent-subprocess/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/subagent/subagent-subprocess/tsconfig.json +++ b/packages/subagent/subagent-subprocess/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index aea05553e4..58b51d1888 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -33,6 +39,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts new file mode 100644 index 0000000000..3a79592ee1 --- /dev/null +++ b/packages/subagent/subagent/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-subagent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent' + +/** Cordis companion plugin name. */ +export const name = 'subagent-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index f93f929241..713e214f04 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index e2d4378743..6ed447dd56 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", @@ -35,6 +41,7 @@ "devDependencies": { "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts new file mode 100644 index 0000000000..08881e4b2b --- /dev/null +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-subagent`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-subagent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' + +/** Cordis companion plugin name. */ +export const name = 'tool-subagent-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index 2aa9d4f14e..25780c367f 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../tasks/tasks" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index 64e1b0cbd7..abffcc2708 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -27,9 +32,11 @@ "vitest": "^4.1.8" }, "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts new file mode 100644 index 0000000000..3579b96cf5 --- /dev/null +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-acp-snapshot`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-acp-snapshot/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-acp-snapshot' + +/** Cordis companion plugin name. */ +export const name = 'acp-snapshot-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/acp-snapshot/tsconfig.json b/packages/support/acp-snapshot/tsconfig.json index 9120df0ad1..893282ce51 100644 --- a/packages/support/acp-snapshot/tsconfig.json +++ b/packages/support/acp-snapshot/tsconfig.json @@ -8,6 +8,11 @@ "src" ], "references": [ - { "path": "../loader-smoke" } + { + "path": "../loader-smoke" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index 423bd3e80d..aa04d85832 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -32,6 +38,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts new file mode 100644 index 0000000000..fc2554aa77 --- /dev/null +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-agent-loop-testkit`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-agent-loop-testkit/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop-testkit' + +/** Cordis companion plugin name. */ +export const name = 'agent-loop-testkit-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/agent-loop-testkit/tsconfig.json b/packages/support/agent-loop-testkit/tsconfig.json index 5e5b3c47f2..d24b4dd988 100644 --- a/packages/support/agent-loop-testkit/tsconfig.json +++ b/packages/support/agent-loop-testkit/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 1cb0e57838..c49771ded9 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -1,6 +1,6 @@ # dsh-invariants -Configurable registry service for package-owned runtime invariant checks. The root plugin registers `ctx.invariants`; it contains no product checks or product-package imports. Packages publish optional `./invariant` companion plugins that contribute their own assertions. +Configurable registry service for package-owned runtime invariant checks. The root plugin registers `ctx.invariants`; it contains no product checks or product-package imports. Every workspace package publishes a `./invariant` companion that registers its exact npm package name. ## Service: `InvariantService` (`ctx.invariants`) @@ -24,6 +24,10 @@ The service owns every registration fiber, while the returned disposer also belo ## Package companions +An ownership-only generated baseline installs no listeners but still reserves its package name through the real service boundary. A package replaces that marked file when it gains a relational check, retaining the same registration. `pnpm run verify-package-invariants` checks every package's source registration, export, published files, dependencies, TypeScript reference, and bundle entry. + +Four companions currently install stateful checks: + | Companion | Registration | Checks | |---|---|---| | `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | sequence, turn/step enclosure, and same-step tool call/result trace | @@ -50,7 +54,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and all four companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. +The standard agent spine mounts the service and the four stateful companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so baseline ownership and stateful checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. ## Model Experience @@ -58,6 +62,6 @@ None, as the service and companions observe runtime events and requests but neve ## Known Limitations and Deferred Work -- The shipped checks cover only the four listed package contracts; a merge-extended event family has no family-specific assertion until its owner publishes one. +- Stateful checks cover only the four listed package contracts; other companions reserve ownership but add no listeners until their packages gain relational assertions. - Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract. - Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index e06e9a3db7..d52dd0a14d 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 754cb46ab9..4bbf218e97 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,7 +1,8 @@ /** * Configurable registry for package-owned runtime invariant contributions. - * Packages register checks from optional `./invariant` companion plugins; - * ordinary package entrypoints stay independent of diagnostics. + * Every workspace package registers its name from a `./invariant` companion; + * ordinary package entrypoints stay independent of diagnostics, and packages + * without relational checks use an ownership-only installer. * * @module @deepseek-ai/dsh-invariants */ diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts new file mode 100644 index 0000000000..b6b4d8ae48 --- /dev/null +++ b/packages/support/invariants/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-invariants`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-invariants/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from './index.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' + +/** Cordis companion plugin name. */ +export const name = 'invariants-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 403f3bda92..4a7e7dd8d8 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -11,22 +11,29 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts new file mode 100644 index 0000000000..50295cbedf --- /dev/null +++ b/packages/support/llm-replay/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-replay`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-llm-replay/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-replay' + +/** Cordis companion plugin name. */ +export const name = 'llm-replay-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/llm-replay/tsconfig.json b/packages/support/llm-replay/tsconfig.json index 95245937ec..673ee51547 100644 --- a/packages/support/llm-replay/tsconfig.json +++ b/packages/support/llm-replay/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index ddba421b41..570ee2c5ca 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -25,9 +30,11 @@ "tsx": "^4.22.4" }, "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts new file mode 100644 index 0000000000..9265a5f8b4 --- /dev/null +++ b/packages/support/loader-smoke/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-loader-smoke`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-loader-smoke/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-loader-smoke' + +/** Cordis companion plugin name. */ +export const name = 'loader-smoke-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/loader-smoke/tsconfig.json b/packages/support/loader-smoke/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/support/loader-smoke/tsconfig.json +++ b/packages/support/loader-smoke/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 9e5f824418..128a8d2c4e 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tasks", - "description": "Background task registry (ctx.tasks) for the DeepSeek Harness \u2014 shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", + "description": "Background task registry (ctx.tasks) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", "version": "0.0.1", "private": true, "type": "module", @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -31,6 +37,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts new file mode 100644 index 0000000000..468fe664b9 --- /dev/null +++ b/packages/tasks/tasks/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tasks`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tasks/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tasks' + +/** Cordis companion plugin name. */ +export const name = 'tasks-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/tasks/tasks/tsconfig.json b/packages/tasks/tasks/tsconfig.json index 392d3f8d8a..e29262ca74 100644 --- a/packages/tasks/tasks/tsconfig.json +++ b/packages/tasks/tasks/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../util/timeout" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index 9e0b19d307..fc1f96417d 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -33,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts new file mode 100644 index 0000000000..fded38c895 --- /dev/null +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-tasks`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-tasks/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-tasks' + +/** Cordis companion plugin name. */ +export const name = 'tool-tasks-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/tasks/tool-tasks/tsconfig.json b/packages/tasks/tool-tasks/tsconfig.json index 9e5411df25..feab4f3be8 100644 --- a/packages/tasks/tool-tasks/tsconfig.json +++ b/packages/tasks/tool-tasks/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../tasks" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/timeout/timeout-policy/package.json b/packages/timeout/timeout-policy/package.json index aa351cb7a6..43dc9dfa28 100644 --- a/packages/timeout/timeout-policy/package.json +++ b/packages/timeout/timeout-policy/package.json @@ -11,23 +11,30 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/timeout/timeout-policy/src/invariant.ts b/packages/timeout/timeout-policy/src/invariant.ts new file mode 100644 index 0000000000..9e7b5b7d4b --- /dev/null +++ b/packages/timeout/timeout-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-timeout-policy`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-timeout-policy/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-timeout-policy' + +/** Cordis companion plugin name. */ +export const name = 'timeout-policy-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/timeout/timeout-policy/tsconfig.json b/packages/timeout/timeout-policy/tsconfig.json index 8c0b47716e..55e50befde 100644 --- a/packages/timeout/timeout-policy/tsconfig.json +++ b/packages/timeout/timeout-policy/tsconfig.json @@ -6,11 +6,26 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../util/timeout" }, - { "path": "../../core/tools" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index bab0f1230c..88d5e9b9c9 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -31,6 +37,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts new file mode 100644 index 0000000000..a5980342f3 --- /dev/null +++ b/packages/todo/tool-todo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-todo`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-todo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-todo' + +/** Cordis companion plugin name. */ +export const name = 'tool-todo-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/todo/tool-todo/tsconfig.json b/packages/todo/tool-todo/tsconfig.json index adf2f25dec..f980e5ead1 100644 --- a/packages/todo/tool-todo/tsconfig.json +++ b/packages/todo/tool-todo/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 84e4dcbda9..7d324d2e6f 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -29,6 +34,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-permission": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", diff --git a/packages/ui/acp/src/invariant.ts b/packages/ui/acp/src/invariant.ts new file mode 100644 index 0000000000..2c081fc48c --- /dev/null +++ b/packages/ui/acp/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-acp`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-acp/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-acp' + +/** Cordis companion plugin name. */ +export const name = 'acp-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 387e0d0c53..5f93e1589d 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../bash/bash" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index 1eef56ae93..e2f0631e8b 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,11 +29,13 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts new file mode 100644 index 0000000000..498d967799 --- /dev/null +++ b/packages/ui/app-boot/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-app-boot`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-app-boot/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot' + +/** Cordis companion plugin name. */ +export const name = 'app-boot-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index 3171312de4..b85dc7f6a2 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../../vendor/include" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index e59fc4aaea..72dd9fe8c3 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -26,6 +31,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", @@ -37,6 +43,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/ui/jsonrpc/src/invariant.ts b/packages/ui/jsonrpc/src/invariant.ts new file mode 100644 index 0000000000..552c312481 --- /dev/null +++ b/packages/ui/jsonrpc/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-jsonrpc`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-jsonrpc/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc' + +/** Cordis companion plugin name. */ +export const name = 'jsonrpc-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/jsonrpc/tsconfig.json b/packages/ui/jsonrpc/tsconfig.json index dcd57ef9af..14a70d8eaa 100644 --- a/packages/ui/jsonrpc/tsconfig.json +++ b/packages/ui/jsonrpc/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../subagent/subagent" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index e38e5a7bf1..bd8164f427 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", @@ -33,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts new file mode 100644 index 0000000000..1a774e2e4c --- /dev/null +++ b/packages/ui/permission/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-permission`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-permission/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-permission' + +/** Cordis companion plugin name. */ +export const name = 'permission-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 8b9cff62b4..3e7b747b2c 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../user-approval" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json index e1bffdf171..f8473af7a3 100644 --- a/packages/ui/stdio/package.json +++ b/packages/ui/stdio/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", @@ -41,6 +47,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts new file mode 100644 index 0000000000..443440a215 --- /dev/null +++ b/packages/ui/stdio/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-stdio/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-stdio' + +/** Cordis companion plugin name. */ +export const name = 'stdio-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts index a3069462ff..4f8cf49e71 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -1,9 +1,9 @@ import { Readable, Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts' @@ -67,10 +67,26 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { /** Register a fake configured agent and cross the supported startup-work boundary. */ function registerReady(ctx: Context, agent: Agent, source: 'startup' | 'resume' = 'startup'): () => void { const dispose = ctx.agents.register(agent) - ctx.emit('agent/session-start', agent, source) + agentEvents(ctx, agent).emit('agent/session-start', source) return dispose } +function emitAgentSessionStart(ctx: Context, agent: Agent, source: 'startup' | 'resume'): void { + agentEvents(ctx, agent).emit('agent/session-start', source) +} + +function emitAgentStatus(ctx: Context, agent: Agent, status: AgentStatus): void { + agentEvents(ctx, agent).emit('agent/status', status) +} + +function emitAgentDisposed(ctx: Context, agent: Agent): void { + agentEvents(ctx, agent).emit('agent/disposed') +} + +function emitSessionEvent(ctx: Context, session: Session, event: SessionEvent): void { + ctx.emit(scopeTarget(session, undefined), 'session/event', session, event) +} + /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ function makeSession(id: string): Session { return { id, header: { id } } as Session @@ -192,23 +208,23 @@ describe('createStdioChat rendering', () => { it('renders text-delta chunks verbatim', async () => { const { ctx, out } = await setup() - ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' })) + emitSessionEvent(ctx, makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' })) expect(out.text()).toContain('hello') }) it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => { const { ctx, out } = await setup() const session = makeSession('main') - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' })) - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' })) - ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' })) + emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' })) + emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' })) + emitSessionEvent(ctx, session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' })) expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer') }) it('ignores stream-chunk types it does not render', async () => { const { ctx, out } = await setup() const before = out.text() - ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' })) + emitSessionEvent(ctx, makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' })) expect(out.text()).toBe(before) }) @@ -217,20 +233,20 @@ describe('createStdioChat rendering', () => { const agent = makeAgent('main') ctx.agents.register(agent) const session = agent.session - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 3] ') - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, { type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } }, - } as SessionEvent) + }) expect(out.text()).toContain('\n> ') }) it('uses the session id as the label for a non-target session', async () => { const { ctx, out } = await setup() // No target exists, so the event's durable identity is the label. - ctx.emit('session/event', makeSession('orphan'), { + emitSessionEvent(ctx, makeSession('orphan'), { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[orphan turn 1] ') @@ -253,7 +269,7 @@ describe('createStdioChat rendering', () => { await ctx.plugin(Object.assign((inner: Context) => { createStdioChat(inner, CONFIG, runtime) }, { inject: ['agents', 'userInteraction'] })) - ctx.emit('session/event', agent.session, { + emitSessionEvent(ctx, agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 5] ') @@ -266,14 +282,14 @@ describe('createStdioChat rendering', () => { const unrelated = makeAgent('unrelated') ctx.agents.register(unrelated) - ctx.emit('agent/session-start', unrelated, 'startup') + emitAgentSessionStart(ctx, unrelated, 'startup') const resumed = makeAgent('resumed') ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' ctx.agents.register(resumed) await new Promise(resolve => setImmediate(resolve)) expect(resumed.sent).toEqual([]) - ctx.emit('agent/session-start', resumed, 'resume') + emitAgentSessionStart(ctx, resumed, 'resume') await new Promise(resolve => setImmediate(resolve)) expect(unrelated.sent).toEqual([]) @@ -283,10 +299,10 @@ describe('createStdioChat rendering', () => { it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const session = makeSession('main') - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' })) - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' })) + emitSessionEvent(ctx, session, { type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } }, - } as SessionEvent) + }) expect(out.text()).toContain('\x1B[2mmid\x1B[0m') }) @@ -297,7 +313,7 @@ describe('createStdioChat rendering', () => { dispose() // After disposal the event belongs to a non-target session, so its durable // identity is rendered directly. - ctx.emit('session/event', agent.session, { + emitSessionEvent(ctx, agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 1] ') @@ -307,8 +323,8 @@ describe('createStdioChat rendering', () => { const { ctx, out } = await setup() const target = makeAgent('main') ctx.agents.register(target) - ctx.emit('agent/disposed', makeAgent('other')) - ctx.emit('session/event', target.session, { + emitAgentDisposed(ctx, makeAgent('other')) + emitSessionEvent(ctx, target.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 1] ') @@ -326,7 +342,7 @@ describe('createStdioChat rendering', () => { input.feed('after hmr') await new Promise(resolve => setImmediate(resolve)) expect(replacement.sent).toEqual([]) - ctx.emit('agent/session-start', replacement, 'resume') + emitAgentSessionStart(ctx, replacement, 'resume') await new Promise(resolve => setImmediate(resolve)) expect(prefixCollision.sent).toEqual([]) @@ -356,28 +372,28 @@ describe('createStdioChat rendering', () => { type: 'tool/call', seq: 1, time: 0, data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }, } as SessionEvent - ctx.emit('session/event', session, callEvent) + emitSessionEvent(ctx, session, callEvent) expect(out.text()).toContain('[tool call] bash({"command":"ls"})') const resultEvent = { type: 'tool/result', seq: 2, time: 0, data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false }, } as SessionEvent - ctx.emit('session/event', session, resultEvent) + emitSessionEvent(ctx, session, resultEvent) expect(out.text()).toContain('[tool result] file.txt') }) it('renders a todo/write session event as a glyphed checklist', async () => { const { ctx, out } = await setup() const session = {} as Session - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, { type: 'todo/write', seq: 1, time: 0, data: { todos: [ { content: 'read the code', status: 'completed' }, { content: 'write the fix', status: 'in_progress' }, { content: 'run the tests', status: 'pending' }, ] }, - } as SessionEvent) + }) const text = out.text() expect(text).toContain('[todos]') expect(text).toContain('[x] read the code') @@ -387,19 +403,19 @@ describe('createStdioChat rendering', () => { it('resets dim styling when a todo/write interrupts reasoning', async () => { const { ctx, out } = await setup() - ctx.emit('session/event', {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) - ctx.emit('session/event', {} as Session, { + emitSessionEvent(ctx, {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) + emitSessionEvent(ctx, {} as Session, { type: 'todo/write', seq: 1, time: 0, data: { todos: [{ content: 'a task', status: 'pending' }] }, - } as SessionEvent) + }) expect(out.text()).toContain('\x1B[2mr\x1B[0m') }) it('resets dim styling when a tool/call interrupts reasoning', async () => { const { ctx, out } = await setup() const session = {} as Session - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) - ctx.emit('session/event', session, { + emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) + emitSessionEvent(ctx, session, { type: 'tool/call', seq: 1, time: 0, data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, } as SessionEvent) @@ -409,10 +425,10 @@ describe('createStdioChat rendering', () => { it('ignores session events it does not render', async () => { const { ctx, out } = await setup() const before = out.text() - ctx.emit('session/event', {} as Session, { + emitSessionEvent(ctx, {} as Session, { type: 'user/message', seq: 1, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, - } as SessionEvent) + }) expect(out.text()).toBe(before) }) }) @@ -799,7 +815,7 @@ describe('createStdioChat input', () => { ctx.agents.register(agent) await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([]) - ctx.emit('agent/session-start', agent, 'startup') + emitAgentSessionStart(ctx, agent, 'startup') await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]]) }) @@ -860,9 +876,9 @@ describe('createStdioChat EOF exit', () => { // Work submitted but no 'running' observed yet — must NOT exit. expect(exit).not.toHaveBeenCalled() // The turn starts, then settles. - ctx.emit('agent/status', agent, 'running') + emitAgentStatus(ctx, agent, 'running') ;(agent as { status: AgentStatus }).status = 'idle' - ctx.emit('agent/status', agent, 'idle') + emitAgentStatus(ctx, agent, 'idle') await flushExit() expect(exit).toHaveBeenCalledWith(0) }) @@ -878,12 +894,12 @@ describe('createStdioChat EOF exit', () => { ctx.agents.register(agent) await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([]) - ctx.emit('agent/session-start', agent, 'startup') + emitAgentSessionStart(ctx, agent, 'startup') await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([[{ type: 'text', text: 'work' }]]) - ctx.emit('agent/status', agent, 'running') + emitAgentStatus(ctx, agent, 'running') ;(agent as { status: AgentStatus }).status = 'idle' - ctx.emit('agent/status', agent, 'idle') + emitAgentStatus(ctx, agent, 'idle') await flushExit() expect(exit).toHaveBeenCalledWith(0) }) @@ -913,14 +929,14 @@ describe('createStdioChat EOF exit', () => { registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) - ctx.emit('agent/status', agent, 'running') // sawRunning = true + emitAgentStatus(ctx, agent, 'running') // sawRunning = true input.finish() await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed ;(agent as { status: AgentStatus }).status = 'idle' // Two idle signals while stdin is already closed: the first arms the timer, // the second must hit the already-scheduled guard, not arm a second. - ctx.emit('agent/status', agent, 'idle') - ctx.emit('agent/status', agent, 'idle') + emitAgentStatus(ctx, agent, 'idle') + emitAgentStatus(ctx, agent, 'idle') await flushExit() expect(exit).toHaveBeenCalledTimes(1) }) @@ -933,8 +949,8 @@ describe('createStdioChat EOF exit', () => { await new Promise(r => setImmediate(r)) input.finish() const other = makeAgent('other') - ctx.emit('agent/status', other, 'running') - ctx.emit('agent/status', other, 'idle') + emitAgentStatus(ctx, other, 'running') + emitAgentStatus(ctx, other, 'idle') await flushExit() expect(exit).not.toHaveBeenCalled() }) @@ -945,11 +961,11 @@ describe('createStdioChat EOF exit', () => { registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) - ctx.emit('agent/status', agent, 'running') + emitAgentStatus(ctx, agent, 'running') ;(agent as { status: AgentStatus }).status = 'running' input.finish() // sawRunning is true, but the agent is still running — the idle gate holds. - ctx.emit('agent/status', agent, 'idle') // a stale/duplicate signal while status stays 'running' + emitAgentStatus(ctx, agent, 'idle') // a stale/duplicate signal while status stays 'running' await flushExit() expect(exit).not.toHaveBeenCalled() }) @@ -998,8 +1014,8 @@ describe('createStdioChat disposal (HMR safety)', () => { // After dispose, status transitions must neither throw nor schedule an exit // (the listener and the EOF-exit path are both torn down). expect(() => { - ctx.emit('agent/status', agent, 'running') - ctx.emit('agent/status', agent, 'idle') + emitAgentStatus(ctx, agent, 'running') + emitAgentStatus(ctx, agent, 'idle') }).not.toThrow() await flushExit() expect(exit).not.toHaveBeenCalled() diff --git a/packages/ui/stdio/tsconfig.json b/packages/ui/stdio/tsconfig.json index e0c578ed32..c37d7ed522 100644 --- a/packages/ui/stdio/tsconfig.json +++ b/packages/ui/stdio/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../user-interaction" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/tool-ask-user/package.json b/packages/ui/tool-ask-user/package.json index 5ee90f0818..d2523ce220 100644 --- a/packages/ui/tool-ask-user/package.json +++ b/packages/ui/tool-ask-user/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,12 +28,14 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/ui/tool-ask-user/src/invariant.ts b/packages/ui/tool-ask-user/src/invariant.ts new file mode 100644 index 0000000000..eebb1ced42 --- /dev/null +++ b/packages/ui/tool-ask-user/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-ask-user`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-ask-user/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user' + +/** Cordis companion plugin name. */ +export const name = 'tool-ask-user-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/tool-ask-user/tsconfig.json b/packages/ui/tool-ask-user/tsconfig.json index c779bad37f..6a55e89abe 100644 --- a/packages/ui/tool-ask-user/tsconfig.json +++ b/packages/ui/tool-ask-user/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../user-interaction" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index fd4f187e35..35d51fbd2f 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -38,6 +44,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/tui/src/invariant.ts b/packages/ui/tui/src/invariant.ts new file mode 100644 index 0000000000..cba5fd9d2e --- /dev/null +++ b/packages/ui/tui/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tui`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tui/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tui' + +/** Cordis companion plugin name. */ +export const name = 'tui-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 9994833308..fcf8105350 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -63,6 +63,11 @@ export async function createTuiTestHarness { await renderAfter(harness, () => { appendUser(harness.session, 'Show the live update.') harness.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, }) harness.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, }) harness.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'block-start', index: 1, blockType: 'text' }, }) harness.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, }) }) @@ -345,9 +346,10 @@ describe('TUI terminal-state snapshots', () => { source: { kind: 'user' }, reason: `Unsafe policy ${CONTROL_PROBE}`, }) + session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { - turn: 7, - reason: { kind: 'error', step: 2, message: `Unsafe turn error ${CONTROL_PROBE}` }, + turn: 1, + reason: { kind: 'error', step: 1, message: `Unsafe turn error ${CONTROL_PROBE}` }, }) }, }, { columns: 100, rows: 34 }) @@ -369,7 +371,7 @@ describe('TUI terminal-state snapshots', () => { const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await harness.terminal.waitForFrame(beforeQuestion) await renderAfter(harness, () => { - harness.ctx.emit('agent/error', harness.agent, 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) + agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) }) await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true }) @@ -427,14 +429,14 @@ describe('TUI terminal-state snapshots', () => { }, { surfaceOp: 'append' }) const assistant = session.append('assistant/message', { turn: 1, - step: 0, + step: 1, provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }], }, { surfaceOp: 'append' }) - session.append('tool/call', { turn: 1, step: 0, callId: CallId('old-tool'), name: 'bash', arguments: '{}' }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' }) const result = session.append('tool/result', { turn: 1, - step: 0, + step: 1, callId: CallId('old-tool'), content: [{ type: 'text', text: 'obsolete output that must disappear' }], isError: false, @@ -470,13 +472,15 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('\r') harness.terminal.send('/unknown-advanced-command') harness.terminal.send('\r') - harness.ctx.emit('agent/error', harness.agent, 3, 1, new Error('provider stream failed after partial output')) + agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output')) + harness.session.append('step/end', { turn: 1, step: 1 }) harness.session.append('turn/end', { - turn: 3, + turn: 1, reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' }, }) + harness.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) harness.session.append('turn/end', { - turn: 4, + turn: 2, reason: { kind: 'interrupted' }, }) }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 27200e0fa9..c4520090d7 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -174,7 +174,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('↑1.3k ↓42') result.agent.status = 'running' - result.ctx.emit('agent/status', result.agent, 'running') + agentEvents(result.ctx, result.agent).emit('agent/status', 'running') result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -182,69 +182,77 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) appendAssistant(result.session, []) - result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } }) - result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } }) - result.session.append('step/start', { turn: 11, step: 0 }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('step/start', { turn: 3, step: 1 }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-start', index: 1, blockType: 'text' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'text-delta', index: 1, text: 'live answer' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-start', index: 2, blockType: 'tool-call' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' }, }) result.session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 3, + step: 1, chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } }, }) await tick() expect(result.terminal.output).toContain('live thought') result.terminal.send('\x12') await tick() - appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 }) + appendAssistant( + result.session, + [{ type: 'text', text: 'final live answer' }], + { inputTokens: 500, outputTokens: 8 }, + { turn: 3, step: 1 }, + ) await tick() expect(result.terminal.output).toContain('Working') @@ -258,17 +266,17 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('assistant/chunk', { turn: 3, - step: 0, + step: 1, chunk: { type: 'text-delta', index: 0, text: 'cleared stream' }, }) result.terminal.send('/clear') result.terminal.send('\r') - appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }]) + appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 }) await tick() expect(result.terminal.output).toContain('answer after clear') result.agent.status = 'idle' - result.ctx.emit('agent/status', result.agent, 'idle') + agentEvents(result.ctx, result.agent).emit('agent/status', 'idle') await tick() expect(result.terminal.progress.at(-1)).toBe(false) await dispose(result) @@ -324,8 +332,8 @@ describe('pi-tui chat lifecycle and transcript', () => { appendUser(session, 'first prompt') appendUser(session, 'second prompt') session.append('assistant/chunk', { - turn: 2, - step: 0, + turn: 1, + step: 1, chunk: { type: 'text-delta', index: 0, text: 'stale partial response' }, }) }, @@ -442,18 +450,25 @@ describe('pi-tui chat lifecycle and transcript', () => { const events = await setup() const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } + unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) - events.ctx.emit('agent/status', unrelatedAgent, 'running') - events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error')) - events.ctx.emit('agent/disposed', unrelatedAgent) - events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure')) - events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } }) - events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } }) - events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted', reason: 'stopped' } }) - events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } }) - events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } }) - events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } }) - events.ctx.emit('agent/disposed', events.agent) + agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running') + agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error')) + agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed') + agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure')) + events.session.append('step/end', { turn: 1, step: 1 }) + events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } }) + events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } }) + events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'stopped' } }) + events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } }) + events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 5, reason: { kind: 'rejected', reason: 'policy' } }) + events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 6, reason: { kind: 'interrupted' } }) + agentEvents(events.ctx, events.agent).emit('agent/disposed') await tick() expect(events.terminal.output).toContain('live failure') expect(events.terminal.output).toContain('durable failure') @@ -546,7 +561,7 @@ describe('tool cards and surface replay', () => { })), ]) for (const [id, name, args] of calls) { - result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args }) + result.session.append('tool/call', { turn: 1, step: 1, callId: id as never, name, arguments: args }) } await tick() expect(result.terminal.output).toContain('$ raw command') @@ -556,23 +571,23 @@ describe('tool cards and surface replay', () => { expect(result.terminal.output).toContain('call presenter boom') expect(result.terminal.output).toContain('Symbol(input)') result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false, + turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true, + turn: 1, step: 1, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false, + turn: 1, step: 1, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false, + turn: 1, step: 1, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false, + turn: 1, step: 1, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false, meta: { value: 1 }, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c7' as never, + turn: 1, step: 1, callId: 'c7' as never, content: [ { type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' }, { type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] }, @@ -581,13 +596,18 @@ describe('tool cards and surface replay', () => { isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false, + turn: 1, step: 1, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false, + turn: 1, step: 1, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false, }, { surfaceOp: 'append' }) result.session.append('tool/result', { - turn: 1, step: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false, + turn: 1, + step: 1, + callId: 'orphan' as never, + content: [{ type: 'text', text: 'orphan result' }], + isError: true, + error: { name: 'InterruptedError', code: 'interrupted' }, }, { surfaceOp: 'append' }) await tick() @@ -623,15 +643,15 @@ describe('tool cards and surface replay', () => { appendUser(result.session, 'old prompt') const assistant = result.session.append('assistant/message', { turn: 1, - step: 0, + step: 1, provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }], }, { surfaceOp: 'append' }) result.session.append('tool/call', { - turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}', + turn: 1, step: 1, callId: 'old-call' as never, name: 'bash', arguments: '{}', }) const toolResult = result.session.append('tool/result', { - turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false, + turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false, }, { surfaceOp: 'append' }) const start = result.session.surface.nodes[0] as number result.session.append('context/message', { @@ -904,6 +924,8 @@ describe('terminal mounting', () => { await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('failed-start-session')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), @@ -919,7 +941,7 @@ describe('terminal mounting', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) session.append('assistant/chunk', { turn: 1, - step: 0, + step: 1, chunk: { type: 'text-delta', index: 0, text: 'must not render' }, }) await tick() diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3a09f80ad8..134b87248b 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../user-interaction" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/user-approval/package.json b/packages/ui/user-approval/package.json index 1696a7b603..098a0cc44b 100644 --- a/packages/ui/user-approval/package.json +++ b/packages/ui/user-approval/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -36,6 +42,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts new file mode 100644 index 0000000000..73dee4f9f1 --- /dev/null +++ b/packages/ui/user-approval/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-user-approval`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-user-approval/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-user-approval' + +/** Cordis companion plugin name. */ +export const name = 'user-approval-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 5592063e45..fe2643cb3f 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' @@ -371,7 +371,7 @@ describe('approval policy (the approval/policy fold)', () => { } const preStep = (ctx: Context, agent: Agent): Promise => - ctx.serial('agent/pre-step', agent, 1, 1, new AbortController().signal) + agentEvents(ctx, agent).serial('agent/pre-step', 1, 1, new AbortController().signal) /** Append a `request/header` snapshot whose system text is exactly `system`. */ function appendHeader(session: Session, system: string): void { diff --git a/packages/ui/user-approval/tsconfig.json b/packages/ui/user-approval/tsconfig.json index fb9a9e6e2d..2fe19338ca 100644 --- a/packages/ui/user-approval/tsconfig.json +++ b/packages/ui/user-approval/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/ui/user-interaction/package.json b/packages/ui/user-interaction/package.json index f4c9c411fd..bcabc951df 100644 --- a/packages/ui/user-interaction/package.json +++ b/packages/ui/user-interaction/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/ui/user-interaction/src/invariant.ts b/packages/ui/user-interaction/src/invariant.ts new file mode 100644 index 0000000000..262681fc06 --- /dev/null +++ b/packages/ui/user-interaction/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-user-interaction`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-user-interaction/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction' + +/** Cordis companion plugin name. */ +export const name = 'user-interaction-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/user-interaction/tsconfig.json b/packages/ui/user-interaction/tsconfig.json index 178ff39f3f..1361d87c20 100644 --- a/packages/ui/user-interaction/tsconfig.json +++ b/packages/ui/user-interaction/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 7074aaa621..51ce4d795d 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts new file mode 100644 index 0000000000..e932e7e35e --- /dev/null +++ b/packages/util/brand/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-brand`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-brand/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-brand' + +/** Cordis companion plugin name. */ +export const name = 'brand-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/brand/tsconfig.json b/packages/util/brand/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/util/brand/tsconfig.json +++ b/packages/util/brand/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/util/home/package.json b/packages/util/home/package.json index efeaf4832c..07795b1a07 100644 --- a/packages/util/home/package.json +++ b/packages/util/home/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts new file mode 100644 index 0000000000..5874a57c1c --- /dev/null +++ b/packages/util/home/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-home`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-home/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-home' + +/** Cordis companion plugin name. */ +export const name = 'home-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/home/tsconfig.json b/packages/util/home/tsconfig.json index 9770ef25d6..67d4461281 100644 --- a/packages/util/home/tsconfig.json +++ b/packages/util/home/tsconfig.json @@ -5,5 +5,9 @@ "outDir": "lib/types" }, "include": ["src"], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index b4f760afe9..601a2941b2 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts new file mode 100644 index 0000000000..c2cedfbb0d --- /dev/null +++ b/packages/util/paths/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-paths`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-paths/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-paths' + +/** Cordis companion plugin name. */ +export const name = 'paths-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/paths/tsconfig.json b/packages/util/paths/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/util/paths/tsconfig.json +++ b/packages/util/paths/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index db8bab3342..312bebd624 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts new file mode 100644 index 0000000000..7516f9e5ed --- /dev/null +++ b/packages/util/retention/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-retention`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-retention/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-retention' + +/** Cordis companion plugin name. */ +export const name = 'retention-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/retention/tsconfig.json b/packages/util/retention/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/util/retention/tsconfig.json +++ b/packages/util/retention/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 381b9d269e..615d21759e 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -11,20 +11,27 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts new file mode 100644 index 0000000000..15140bb880 --- /dev/null +++ b/packages/util/timeout/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-timeout`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-timeout/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-timeout' + +/** Cordis companion plugin name. */ +export const name = 'timeout-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/timeout/tsconfig.json b/packages/util/timeout/tsconfig.json index 749cb0208e..d970a00263 100644 --- a/packages/util/timeout/tsconfig.json +++ b/packages/util/timeout/tsconfig.json @@ -7,5 +7,9 @@ "include": [ "src" ], - "references": [] + "references": [ + { + "path": "../../support/invariants" + } + ] } diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 23ed7157a2..ec1d33f4d4 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -33,13 +39,14 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-local": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts new file mode 100644 index 0000000000..008fe2f5e1 --- /dev/null +++ b/packages/web/tool-web/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-web`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-web/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-web' + +/** Cordis companion plugin name. */ +export const name = 'tool-web-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/tool-web/tsconfig.json b/packages/web/tool-web/tsconfig.json index 5226425ec6..f6684272ce 100644 --- a/packages/web/tool-web/tsconfig.json +++ b/packages/web/tool-web/tsconfig.json @@ -6,13 +6,32 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../llm/llm" }, - { "path": "../../core/tools" }, - { "path": "../../core/system-prompt" }, - { "path": "../../timeout/timeout-policy" }, - { "path": "../web" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../timeout/timeout-policy" + }, + { + "path": "../web" + }, + { + "path": "../../support/invariants" + } ] } diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index 1e1d7ea71b..5c42bfa09c 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -30,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts new file mode 100644 index 0000000000..ef61e2a611 --- /dev/null +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web-fetch-local`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web-fetch-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-fetch-local' + +/** Cordis companion plugin name. */ +export const name = 'web-fetch-local-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-fetch-local/tsconfig.json b/packages/web/web-fetch-local/tsconfig.json index c6fb75a5c1..29e7c5078c 100644 --- a/packages/web/web-fetch-local/tsconfig.json +++ b/packages/web/web-fetch-local/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../web" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 42471006c0..b38f552957 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts new file mode 100644 index 0000000000..8781949be9 --- /dev/null +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-deepseek`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web-search-deepseek/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-deepseek' + +/** Cordis companion plugin name. */ +export const name = 'web-search-deepseek-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index aa7c949fec..e9610ea5c9 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../web" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 240909e24a..7d6b802d2e 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts new file mode 100644 index 0000000000..a2dd956625 --- /dev/null +++ b/packages/web/web-search-exa/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-exa`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web-search-exa/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-exa' + +/** Cordis companion plugin name. */ +export const name = 'web-search-exa-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json index aa7c949fec..e9610ea5c9 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../web" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 26d077fada..9aa7080431 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts new file mode 100644 index 0000000000..fe82c79dae --- /dev/null +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-perplexity`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web-search-perplexity/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-perplexity' + +/** Cordis companion plugin name. */ +export const name = 'web-search-perplexity-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json index aa7c949fec..e9610ea5c9 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../web" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/web/web/package.json b/packages/web/web/package.json index b94f7ba685..7b732ec819 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -11,17 +11,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -29,6 +35,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts new file mode 100644 index 0000000000..b9b1b0d45d --- /dev/null +++ b/packages/web/web/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-web`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-web/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web' + +/** Cordis companion plugin name. */ +export const name = 'web-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web/tsconfig.json b/packages/web/web/tsconfig.json index e9de391ba1..d145ddb6ee 100644 --- a/packages/web/web/tsconfig.json +++ b/packages/web/web/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 327e6ec3a9..dd055ad9ec 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -23,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -34,6 +40,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts new file mode 100644 index 0000000000..cd1f0e475b --- /dev/null +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-workflow`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-tool-workflow/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' + +/** Cordis companion plugin name. */ +export const name = 'tool-workflow-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json index f66eda75a7..c08ae597f2 100644 --- a/packages/workflow/tool-workflow/tsconfig.json +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../workflow" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index 1cd6e07e17..a915229449 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./worker": { "types": "./lib/types/worker.d.ts", "default": "./lib/worker.cjs" @@ -20,6 +24,7 @@ }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", @@ -29,6 +34,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts new file mode 100644 index 0000000000..6bcc40862c --- /dev/null +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-workflow-workerthread`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-workflow-workerthread/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-workerthread' + +/** Cordis companion plugin name. */ +export const name = 'workflow-workerthread-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow-workerthread/tsconfig.json b/packages/workflow/workflow-workerthread/tsconfig.json index 730a3e61d9..37d90aaea1 100644 --- a/packages/workflow/workflow-workerthread/tsconfig.json +++ b/packages/workflow/workflow-workerthread/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../workflow" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/workflow/workflow-workerthread/tsdown.config.ts b/packages/workflow/workflow-workerthread/tsdown.config.ts index 8ebd93d89f..962a3d9078 100644 --- a/packages/workflow/workflow-workerthread/tsdown.config.ts +++ b/packages/workflow/workflow-workerthread/tsdown.config.ts @@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown' */ export default defineConfig([ { - entry: ['lib/types/index.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 476866382a..b8ce1ebc7f 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -11,11 +11,16 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -31,6 +37,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts new file mode 100644 index 0000000000..b552021ca4 --- /dev/null +++ b/packages/workflow/workflow/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Generated invariant ownership companion for `@deepseek-ai/dsh-workflow`. + * Replace this file with package-owned checks while preserving its registration. + * + * @generated scripts/gen-package-invariants.ts + * @module @deepseek-ai/dsh-workflow/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-workflow' + +/** Cordis companion plugin name. */ +export const name = 'workflow-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow/tsconfig.json b/packages/workflow/workflow/tsconfig.json index 6ec42e0bfe..76ad9f725a 100644 --- a/packages/workflow/workflow/tsconfig.json +++ b/packages/workflow/workflow/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43aae285c7..b13f346cd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: packages/bash/bash: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -228,6 +231,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -247,6 +253,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -284,6 +293,9 @@ importers: '@deepseek-ai/dsh-home': specifier: workspace:^ version: link:../../util/home + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -320,6 +332,9 @@ importers: packages/code-runtime/code-runtime: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -333,12 +348,18 @@ importers: '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../code-runtime + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/compact/compact: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -407,6 +428,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -447,6 +471,9 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -493,6 +520,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -606,6 +636,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -628,6 +661,9 @@ importers: '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../../code-runtime/code-runtime + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -667,6 +703,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -770,6 +809,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -801,6 +843,9 @@ importers: specifier: workspace:^ version: link:../../ui/app-boot devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -828,6 +873,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -870,6 +918,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -886,6 +937,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -898,6 +952,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -932,6 +989,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../fs-policy + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -966,6 +1026,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1003,6 +1066,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1021,6 +1087,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1052,6 +1121,9 @@ importers: '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../hook-protocol + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1098,6 +1170,9 @@ importers: '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../hook-protocol + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1122,6 +1197,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1132,6 +1210,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -1148,6 +1229,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -1164,6 +1248,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -1183,6 +1270,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1204,6 +1294,9 @@ importers: packages/sandbox/sandbox: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1220,6 +1313,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1239,6 +1335,9 @@ importers: specifier: ^15.0.0 version: 15.0.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1270,6 +1369,9 @@ importers: '@deepseek-ai/dsh-hooks-codex': specifier: workspace:^ version: link:../../hooks/hooks-codex + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -1301,6 +1403,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1313,6 +1418,12 @@ importers: packages/session-persistence/session-persistence: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1326,6 +1437,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1342,6 +1456,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1358,6 +1475,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1377,6 +1497,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1396,6 +1519,9 @@ importers: '@deepseek-ai/dsh-home': specifier: workspace:^ version: link:../../util/home + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -1412,6 +1538,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1436,6 +1565,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1455,6 +1587,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1477,6 +1612,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1504,6 +1642,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1535,6 +1676,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1678,6 +1822,9 @@ importers: packages/subagent/subagent-subprocess: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1694,6 +1841,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1728,6 +1878,9 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1740,6 +1893,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1768,6 +1924,9 @@ importers: packages/support/llm-replay: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1784,6 +1943,9 @@ importers: specifier: ^4.22.4 version: 4.22.4 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1796,6 +1958,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1815,6 +1980,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1836,6 +2004,9 @@ importers: packages/timeout/timeout-policy: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1860,6 +2031,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1966,6 +2140,9 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) @@ -1985,6 +2162,9 @@ importers: '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../../examples/agent-spine-demo + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2016,6 +2196,9 @@ importers: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -2044,6 +2227,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2062,6 +2248,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2096,6 +2285,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2139,6 +2331,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2160,6 +2355,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2169,30 +2367,45 @@ importers: packages/util/brand: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/home: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/paths: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/retention: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/timeout: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -2206,6 +2419,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2246,6 +2462,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2259,6 +2478,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -2275,6 +2497,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -2288,6 +2513,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -2301,6 +2529,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -2317,6 +2548,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2350,6 +2584,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 224f8540b8..2a9cf01bdb 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -93,37 +93,6 @@ function workspaceManifests(): WorkspaceManifest[] { return manifests } -const dshPackageFiles = [ - 'lib/index.js', - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', -] as const - -const dshBinPackageFiles = [ - 'lib/index.js', - 'lib/bin.js', - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', -] as const - -const dshWorkerPackageFiles = [ - 'lib/index.js', - 'lib/worker.cjs', - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', -] as const - -const dshInvariantPackageFiles = [ - 'lib/index.js', - 'lib/invariant.js', - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', -] as const - const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-scripts': [ @@ -139,25 +108,18 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] - if (extras.length > 0) { - return [ - 'lib/index.js', - ...manifest.bin ? ['lib/bin.js'] : [], - ...extras, - 'lib/types/**/*.d.ts', - 'lib/types/**/*.d.ts.map', - 'src', - ] - } - if (manifest.bin) return dshBinPackageFiles - // Package-owned diagnostic companions are separately bundled optional - // entries; their source stays in the owner package without bloating root. - if (manifest.exports?.['./invariant']) return dshInvariantPackageFiles - // A declared "./worker" subpath export sanctions the one extra runtime - // bundle a worker-thread entry needs (and NodeNext/publint then validate - // that subpath's targets like any other export). - if (manifest.exports?.['./worker']) return dshWorkerPackageFiles - return dshPackageFiles + return [ + 'lib/index.js', + // Every package publishes its invariant ownership companion as a separate + // bundle; the package-invariant gate validates the companion itself. + 'lib/invariant.js', + ...manifest.bin ? ['lib/bin.js'] : [], + ...manifest.exports?.['./worker'] ? ['lib/worker.cjs'] : [], + ...extras, + 'lib/types/**/*.d.ts', + 'lib/types/**/*.d.ts.map', + 'src', + ] } function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { diff --git a/scripts/gen-package-invariants.ts b/scripts/gen-package-invariants.ts new file mode 100644 index 0000000000..d30ce5a3eb --- /dev/null +++ b/scripts/gen-package-invariants.ts @@ -0,0 +1,44 @@ +/** Generate or verify package-owned invariant companion baselines. */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { + GENERATED_INVARIANT_MARKER, + collectPackageInvariantViolations, + formatPackageInvariantViolation, + packageInvariantOwners, + renderBaselineInvariant, +} from './package-invariants.ts' + +const root = resolve(import.meta.dirname, '..') +const check = process.argv.includes('--check') + +if (!check) { + let generated = 0 + for (const owner of packageInvariantOwners(root)) { + const path = resolve(root, owner.sourcePath) + let current: string | undefined + try { + current = readFileSync(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (current !== undefined && !current.includes(GENERATED_INVARIANT_MARKER)) continue + const expected = renderBaselineInvariant(owner) + if (current === expected) continue + writeFileSync(path, expected) + generated += 1 + } + console.log(`gen-package-invariants: wrote ${generated} generated baseline companion(s).`) +} + +const violations = collectPackageInvariantViolations(root) +if (violations.length > 0) { + console.error('verify-package-invariants: violations found:') + for (const violation of violations) { + console.error(` ${formatPackageInvariantViolation(root, violation)}`) + } + process.exit(1) +} + +console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} package companion(s) conform.`) diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts new file mode 100644 index 0000000000..698969b3d7 --- /dev/null +++ b/scripts/package-invariants.spec.ts @@ -0,0 +1,104 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + collectPackageInvariantViolations, + packageInvariantOwners, + renderBaselineInvariant, +} from './package-invariants.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(options: { + packageName?: string + source?: string + invariantExport?: boolean + invariantDependency?: boolean + invariantReference?: boolean + buildEntry?: boolean +} = {}): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-')) + roots.push(root) + const dir = join(root, 'packages/core/probe') + mkdirSync(join(dir, 'src'), { recursive: true }) + const packageName = options.packageName ?? '@deepseek-ai/dsh-probe' + const manifest = { + name: packageName, + exports: options.invariantExport === false ? {} : { + './invariant': { + types: './lib/types/invariant.d.ts', + default: './lib/invariant.js', + }, + }, + files: ['lib/index.js', 'lib/invariant.js', 'src'], + peerDependencies: options.invariantDependency === false ? {} : { + '@deepseek-ai/dsh-invariants': '^0.0.1', + }, + devDependencies: options.invariantDependency === false ? {} : { + '@deepseek-ai/dsh-invariants': 'workspace:^', + }, + } + writeFileSync(join(dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ + references: options.invariantReference === false ? [] : [{ path: '../../support/invariants' }], + }, null, 2)}\n`) + const owner = packageInvariantOwners(root)[0]! + writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? renderBaselineInvariant(owner)) + writeFileSync( + join(dir, 'tsdown.config.ts'), + options.buildEntry === false ? "export default { entry: ['lib/types/index.js'] }\n" : "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n", + ) + return root +} + +describe('package invariant gate', () => { + it('accepts a generated owner companion with publication metadata', () => { + expect(collectPackageInvariantViolations(fixture())).toEqual([]) + }) + + it('rejects missing publication metadata and build output', () => { + const violations = collectPackageInvariantViolations(fixture({ + invariantExport: false, + invariantDependency: false, + invariantReference: false, + buildEntry: false, + })) + expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([ + expect.stringContaining('exports["./invariant"]'), + expect.stringContaining('peerDependency'), + expect.stringContaining('devDependency'), + expect.stringContaining('TypeScript project references'), + expect.stringContaining('must bundle lib/types/invariant.js'), + ])) + }) + + it('rejects foreign, duplicate, and unresolved registrations', () => { + const source = ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const selected = process.env.PACKAGE_NAME +export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => { + ctx.invariants.register('@deepseek-ai/dsh-foreign', () => {}) + return ctx.invariants.register(selected!, () => {}) +} +` + const violations = collectPackageInvariantViolations(fixture({ source })) + expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([ + expect.stringContaining('must resolve to a local string constant'), + expect.stringContaining('must register exactly its own package name'), + ])) + }) + + it('rejects edits to a generated baseline', () => { + const root = fixture() + const path = join(root, 'packages/core/probe/src/invariant.ts') + writeFileSync(path, `${renderBaselineInvariant(packageInvariantOwners(root)[0]!)}// stale\n`) + expect(collectPackageInvariantViolations(root).map(violation => violation.message)) + .toContain('generated baseline is stale; run pnpm run gen-package-invariants') + }) +}) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts new file mode 100644 index 0000000000..45fb64829f --- /dev/null +++ b/scripts/package-invariants.ts @@ -0,0 +1,283 @@ +/** + * Package-invariant companion discovery, generation, and structural checks. + * The runtime registry stays product-independent; this gate makes ownership + * exhaustive across packages without centralizing package checks. + */ + +import { existsSync, globSync, readFileSync } from 'node:fs' +import { basename, dirname, relative, resolve, sep } from 'node:path' +import ts from 'typescript' + +/** Marker identifying baseline companions owned by this generator. */ +export const GENERATED_INVARIANT_MARKER = '@generated scripts/gen-package-invariants.ts' + +interface PackageManifest { + name?: string + exports?: Record + files?: string[] + peerDependencies?: Record + devDependencies?: Record +} + +/** One package and the files participating in its invariant publication contract. */ +export interface PackageInvariantOwner { + readonly dir: string + readonly manifestPath: string + readonly sourcePath: string + readonly packageName: string +} + +/** One gate violation with a repo-relative owner path. */ +export interface PackageInvariantViolation { + readonly path: string + readonly message: string +} + +/** Discover every package under the repository package tree. */ +export function packageInvariantOwners(root: string): PackageInvariantOwner[] { + return globSync('packages/*/*/package.json', { cwd: root }) + .map(path => path.split(sep).join('/')) + .sort() + .map((manifestPath) => { + const manifest = readManifest(resolve(root, manifestPath)) + if (manifest.name === undefined || manifest.name === '') { + throw new Error(`${manifestPath}: package invariant owner must declare a package name`) + } + const dir = dirname(manifestPath) + return { + dir, + manifestPath, + sourcePath: `${dir}/src/invariant.ts`, + packageName: manifest.name, + } + }) +} + +/** Render the generated ownership-only companion for a package without custom checks. */ +export function renderBaselineInvariant(owner: PackageInvariantOwner): string { + const serviceImport = owner.packageName === '@deepseek-ai/dsh-invariants' + ? './index.ts' + : '@deepseek-ai/dsh-invariants' + const pluginName = `${basename(owner.dir)}-invariant` + return `/** + * Generated invariant ownership companion for \`${owner.packageName}\`. + * Replace this file with package-owned checks while preserving its registration. + * + * ${GENERATED_INVARIANT_MARKER} + * @module ${owner.packageName}/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '${serviceImport}' + +const PACKAGE_NAME = '${owner.packageName}' + +/** Cordis companion plugin name. */ +export const name = '${pluginName}' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** Reserve this package's invariant ownership until it adds relational checks. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ +` +} + +/** Return all violations of the package-invariant companion contract. */ +export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { + const violations: PackageInvariantViolation[] = [] + for (const owner of packageInvariantOwners(root)) { + const manifest = readManifest(resolve(root, owner.manifestPath)) + checkManifest(owner, manifest, violations) + checkBuild(owner, root, violations) + checkSource(owner, root, violations) + } + return violations +} + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest +} + +function addViolation( + violations: PackageInvariantViolation[], + path: string, + message: string, +): void { + violations.push({ path, message }) +} + +function checkManifest( + owner: PackageInvariantOwner, + manifest: PackageManifest, + violations: PackageInvariantViolation[], +): void { + const invariantExport = manifest.exports?.['./invariant'] + if (typeof invariantExport !== 'object' + || invariantExport.types !== './lib/types/invariant.d.ts' + || invariantExport.default !== './lib/invariant.js') { + addViolation( + violations, + owner.manifestPath, + 'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js', + ) + } + if (!manifest.files?.includes('lib/invariant.js')) { + addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js') + } + if (owner.packageName === '@deepseek-ai/dsh-invariants') return + if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') { + addViolation( + violations, + owner.manifestPath, + '@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency', + ) + } + if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') { + addViolation( + violations, + owner.manifestPath, + '@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency', + ) + } +} + +function checkBuild( + owner: PackageInvariantOwner, + root: string, + violations: PackageInvariantViolation[], +): void { + const tsconfigPath = `${owner.dir}/tsconfig.json` + const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as { + references?: Array<{ path?: string }> + } + if (owner.packageName !== '@deepseek-ai/dsh-invariants' + && !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) { + addViolation( + violations, + tsconfigPath, + 'TypeScript project references must include ../../support/invariants', + ) + } + + const configPath = `${owner.dir}/tsdown.config.ts` + if (!existsSync(resolve(root, configPath))) return + const source = readFileSync(resolve(root, configPath), 'utf8') + if (!source.includes('lib/types/invariant.js')) { + addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js') + } +} + +function checkSource( + owner: PackageInvariantOwner, + root: string, + violations: PackageInvariantViolation[], +): void { + const absolutePath = resolve(root, owner.sourcePath) + if (!existsSync(absolutePath)) { + addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion') + return + } + const sourceText = readFileSync(absolutePath, 'utf8') + if (sourceText.includes(GENERATED_INVARIANT_MARKER) + && sourceText !== renderBaselineInvariant(owner)) { + addViolation( + violations, + owner.sourcePath, + 'generated baseline is stale; run pnpm run gen-package-invariants', + ) + } + + const sourceFile = ts.createSourceFile( + absolutePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) + const constants = topLevelStringConstants(sourceFile) + const registrations: string[] = [] + const unresolved: number[] = [] + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) { + const argument = node.arguments[0] + const packageName = argument === undefined ? undefined : stringValue(argument, constants) + if (packageName === undefined) unresolved.push(sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1) + else registrations.push(packageName) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + + for (const line of unresolved) { + addViolation( + violations, + owner.sourcePath, + `line ${line}: ctx.invariants.register package name must resolve to a local string constant`, + ) + } + if (registrations.length !== 1 || registrations[0] !== owner.packageName) { + addViolation( + violations, + owner.sourcePath, + `must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`, + ) + } + for (const exportedName of ['name', 'inject', 'apply']) { + if (!hasNamedExport(sourceFile, exportedName)) { + addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) + } + } +} + +function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap { + const constants = new Map() + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue + const value = stringValue(declaration.initializer, constants) + if (value !== undefined) constants.set(declaration.name.text, value) + } + } + return constants +} + +function stringValue(node: ts.Expression, constants: ReadonlyMap): string | undefined { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text + if (ts.isIdentifier(node)) return constants.get(node.text) + return undefined +} + +function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean { + return ts.isPropertyAccessExpression(expression) + && expression.name.text === 'register' + && ts.isPropertyAccessExpression(expression.expression) + && expression.expression.name.text === 'invariants' +} + +function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean { + return sourceFile.statements.some((statement) => { + if (!ts.isVariableStatement(statement) + || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false + return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name) + }) +} + +/** Format violations for the command-line gate. */ +export function formatPackageInvariantViolation( + root: string, + violation: PackageInvariantViolation, +): string { + const path = resolve(root, violation.path) + return `${relative(root, path)}: ${violation.message}` +} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f46de4475a..8e2a7f961c 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -204,6 +204,7 @@ function ciPrimaryGates(): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), + pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('typecheck', 'typecheck'), lintGate(), @@ -229,6 +230,7 @@ function ciStaticGates(): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), + pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), ...staticDemoSmokeGates(), ...docSyncLeafGates(), @@ -314,6 +316,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { pnpmScript('knip', 'knip'), pnpmScript('publint', 'publint', artifactOptions), pnpmScript('constraints', 'constraints'), + pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', ...artifactOptions, diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts new file mode 100644 index 0000000000..77150abb74 --- /dev/null +++ b/scripts/test-invariants.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context, Service } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { packageInvariantOwners } from './package-invariants.ts' +import { MANUAL_INVARIANT_TESTS, testInvariantCompanions } from './test-invariants.ts' + +declare module 'cordis' { + interface Context { + testInvariantProbe: TestInvariantProbe + } +} + +class TestInvariantProbe extends Service { + constructor(ctx: Context) { + super(ctx, 'testInvariantProbe') + } +} + +describe('global test invariant host', () => { + it('loads every companion and reserves every package name with enabled checks', async () => { + const ctx = new Context() + await ctx.plugin(TestInvariantProbe) + + const owners = packageInvariantOwners(process.cwd()) + expect(Object.keys(testInvariantCompanions)).toHaveLength(owners.length) + const unreserved: string[] = [] + for (const owner of owners) { + try { + const dispose = ctx.invariants.register(owner.packageName, () => {}) + unreserved.push(owner.packageName) + dispose() + } catch (error) { + expect(error).toHaveProperty( + 'message', + `invariants: package "${owner.packageName}" is already registered`, + ) + } + } + expect(unreserved).toEqual([]) + }) + + it('executes each companion registration with its owning package name', async () => { + const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) + const registrations = new Map() + const register = vi.fn((_packageName: string, installer: InvariantInstaller) => { + expect(typeof installer).toBe('function') + return () => {} + }) + const fakeContext = { invariants: { register } } as unknown as Context + for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) { + const path = rawPath.replace(/^\.\.\//, '') + await companion.apply(fakeContext) + const call = register.mock.calls.at(-1) + if (call === undefined) throw new Error(`${path}: companion did not register`) + registrations.set(path, call[0]) + } + expect(registrations).toEqual(owners) + }) + + it('limits manual composition to focused invariant topology tests', () => { + expect(MANUAL_INVARIANT_TESTS).toEqual([ + '/packages/support/invariants/tests/service.spec.ts', + '/packages/core/session/tests/invariant.spec.ts', + '/packages/core/agent/tests/invariant.spec.ts', + '/packages/core/scope/tests/invariant.spec.ts', + '/packages/core/agent-loop/tests/invariant.spec.ts', + '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', + ]) + }) +}) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts new file mode 100644 index 0000000000..584db3d5d7 --- /dev/null +++ b/scripts/test-invariants.ts @@ -0,0 +1,104 @@ +/** + * Vitest-wide invariant host. Ordinary Cordis roots receive the invariant + * service with global enablement and every package companion before their first + * plugin starts. Focused invariant tests own their service topology explicitly. + */ + +import { expect } from 'vitest' +import { RegistryService } from 'cordis' +import type { Context, Plugin } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' + +declare global { + interface ImportMeta { + /** Eager Vite module-glob expansion used by the Vitest setup file. */ + glob(pattern: string, options: { eager: true }): Record + } +} + +/** Loader-safe shape shared by every package invariant companion. */ +export interface TestInvariantCompanion { + readonly name: string + readonly inject: readonly string[] + apply(ctx: Context): Promise<() => void> +} + +/** Every package companion, discovered eagerly so coverage observes each registration. */ +export const testInvariantCompanions: Readonly> = + import.meta.glob('../packages/*/*/src/invariant.ts', { eager: true }) + +/** Tests that exercise selection or companion lifecycle with a deliberately hand-built service tree. */ +export const MANUAL_INVARIANT_TESTS = [ + '/packages/support/invariants/tests/service.spec.ts', + '/packages/core/session/tests/invariant.spec.ts', + '/packages/core/agent/tests/invariant.spec.ts', + '/packages/core/scope/tests/invariant.spec.ts', + '/packages/core/agent-loop/tests/invariant.spec.ts', + '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', +] as const + +interface InvariantHost { + readonly fibers: readonly PluginFiber[] + readonly byCallback: ReadonlyMap +} + +type PluginFiber = ReturnType + +const hosts = new WeakMap() +// eslint-disable-next-line @typescript-eslint/unbound-method -- every call below supplies its RegistryService receiver explicitly. +const originalPlugin = RegistryService.prototype.plugin + +RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) { + if (usesManualInvariantTree()) return originalPlugin.call(this, plugin, config, getOuterStack) + + const root = this.ctx.root + const host = hosts.get(root) ?? startInvariantHost(root) + const callback = this.resolve(plugin) + const existing = callback === undefined ? undefined : host.byCallback.get(callback) + if (existing !== undefined) return existing + + const fiber = originalPlugin.call(this, plugin, config, getOuterStack) + // A root-level await is the test's composition boundary. Nested plugin + // fibers must not await their own companion parent through the global host. + if (this.ctx !== root) return fiber + return joinInvariantStartup(fiber, host.fibers) +} + +function usesManualInvariantTree(): boolean { + const testPath = expect.getState().testPath?.replaceAll('\\', '/') ?? '' + return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path)) +} + +function startInvariantHost(root: Context): InvariantHost { + const fibers: PluginFiber[] = [] + const byCallback = new Map() + const mount = (plugin: Plugin, config?: unknown): void => { + const fiber = originalPlugin.call(root.registry, plugin, config) + const callback = root.registry.resolve(plugin) + if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin') + fibers.push(fiber) + byCallback.set(callback, fiber) + } + + mount(InvariantService, { enabled: true }) + for (const [path, companion] of Object.entries(testInvariantCompanions).sort(([left], [right]) => left.localeCompare(right))) { + if (!companion.inject.includes('invariants')) { + throw new Error(`test invariants: ${path} must inject the invariant service`) + } + mount(companion) + } + + const host = { fibers, byCallback } + hosts.set(root, host) + return host +} + +function joinInvariantStartup(fiber: PluginFiber, invariantFibers: readonly PluginFiber[]): PluginFiber { + const readiness = fiber.await().then(async (loaded) => { + await Promise.all(invariantFibers.map(invariant => invariant.await())) + return loaded + }) + const joined = Object.create(fiber) as PluginFiber + joined.then = readiness.then.bind(readiness) + return joined +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 5efebe5b0b..31fa53c675 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -3,8 +3,9 @@ import { defineConfig } from 'tsdown' /** * JS bundling for vendored Cordis and Harness TypeScript packages. * TypeScript source is compiled first by `tsc -b tsconfig.build.json`; tsdown - * reads only the emitted JS under lib/types and writes lib/index.* runtime - * bundles. Declarations are NOT produced here, hence `dts: false`. + * reads only the emitted JS under lib/types and writes the package root and + * invariant companion runtime bundles. Declarations are NOT produced here, + * hence `dts: false`. * * Per-package shape overrides live in `/tsdown.config.ts` * (schemastery: dual ESM+CJS; logger-console: extra browser entry). @@ -14,7 +15,9 @@ export default defineConfig({ // `workspace: true` would discover package manifests outside that bundle set. Landlock // platform packages contain only a prebuilt native binary, so they have no JS entry. workspace: ['vendor/*', 'packages/*/*'], - entry: ['lib/types/index.js'], + // The brace glob admits the package companion when present while retaining the + // index-only build for vendored Cordis packages outside the Harness package tree. + entry: ['lib/types/{index,invariant}.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/vitest.config.ts b/vitest.config.ts index c0946e2e10..4fdcf012f0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ // to source; native resolution would fall through to absent `lib/` outputs. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { + setupFiles: ['./scripts/test-invariants.ts'], include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts'], coverage: { provider: 'v8', diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index adc8b3f3b2..ab22640c79 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ // through the root tsconfig paths map; the native option cannot do this. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { + setupFiles: ['./scripts/test-invariants.ts'], include: ['packages/*/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index fb2e890eb7..368f04f423 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ // through the root tsconfig paths map; the native option cannot do this. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { + setupFiles: ['./scripts/test-invariants.ts'], include: [ 'examples/*/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts', diff --git a/website/zh-CN/api/harness/invariants.md b/website/zh-CN/api/harness/invariants.md index b87ea93aa7..e55dd664c7 100644 --- a/website/zh-CN/api/harness/invariants.md +++ b/website/zh-CN/api/harness/invariants.md @@ -6,7 +6,7 @@ Package-owned invariant registry with global and regex-based selection. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L94) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L95) ### ctx.invariants.register(packageName, installer) @@ -29,4 +29,4 @@ Register one package's invariant installer. The package name is reserved even wh **Returns** an effect-scoped disposer for the registration. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L136) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L137) From 36e99e737b8dfa50fb62bdc9f15dd38bb4da36a2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:24:32 +0800 Subject: [PATCH 04/30] test(sandbox): pack invariant service peer --- packages/sandbox/sandbox-local/tests/packed-install.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 8519357c1b..9fcfe23de8 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -26,6 +26,7 @@ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox', 'packages/llm/llm', 'packages/util/brand', + 'packages/support/invariants', ] /** ELF `e_machine` (offset 18, LE) for this host: x86-64 = 62, AArch64 = 183. */ From 941b0411d80fba789bdd237946d406593ee13df9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:38:37 +0800 Subject: [PATCH 05/30] feat(invariants): implement package runtime checks --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 +- docs/rfc/INDEX.md | 1 + ...kage-invariant-runtime-contracts.i18n.yaml | 6 + ...-19-package-invariant-runtime-contracts.md | 65 +++++ ...-package-invariant-runtime-contracts.zh.md | 65 +++++ ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 8 +- ...7-19-package-owned-invariant-service.zh.md | 8 +- package.json | 3 +- packages/AGENTS.md | 2 +- packages/bash/bash-local/src/invariant.ts | 27 ++- packages/bash/bash-sandbox/src/invariant.ts | 29 ++- packages/bash/bash/src/invariant.ts | 20 +- packages/bash/tool-bash/src/invariant.ts | 33 ++- .../code-runtime-worker/src/invariant.ts | 24 +- .../code-runtime/src/invariant.ts | 28 ++- .../code-runtime/tests/service.spec.ts | 14 ++ .../compact/compact-basic/src/invariant.ts | 45 +++- .../compact-basic/tests/compact-basic.spec.ts | 25 +- packages/compact/compact/src/invariant.ts | 20 +- .../context/time-context/src/invariant.ts | 26 +- .../workspace-context/src/invariant.ts | 26 +- packages/cordis/tool-cordis/src/invariant.ts | 27 ++- packages/core/system-prompt/src/invariant.ts | 27 ++- packages/core/tools/src/invariant.ts | 30 ++- packages/examples/acp-demo/src/invariant.ts | 23 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 9 +- .../agent-spine-demo/src/invariant.ts | 23 +- .../agent-spine-demo/tests/agent-core.spec.ts | 9 +- packages/examples/cli-demo/src/invariant.ts | 23 +- .../examples/cli-demo/tests/cli-demo.spec.ts | 9 +- .../examples/jsonrpc-demo/src/invariant.ts | 21 +- packages/examples/stdio-demo/src/invariant.ts | 23 +- .../stdio-demo/tests/stdio-agent.spec.ts | 9 +- packages/fs/fs-local/src/invariant.ts | 26 +- packages/fs/fs-policy/src/invariant.ts | 25 +- packages/fs/fs/src/invariant.ts | 20 +- packages/fs/tool-fs-search/src/invariant.ts | 28 ++- packages/fs/tool-fs/src/invariant.ts | 28 ++- .../guard/repeat-tool-guard/src/invariant.ts | 24 +- packages/hooks/hook-protocol/src/invariant.ts | 30 ++- packages/hooks/hooks-claude/src/invariant.ts | 39 ++- .../hooks-claude/tests/invariant.spec.ts | 26 ++ packages/hooks/hooks-codex/src/invariant.ts | 37 ++- .../hooks/hooks-codex/tests/invariant.spec.ts | 26 ++ packages/llm/llm-deepseek/src/invariant.ts | 26 +- packages/llm/llm-pi-ai/src/invariant.ts | 26 +- packages/llm/llm/src/invariant.ts | 26 +- packages/llm/token-meter/src/invariant.ts | 27 ++- packages/mcp/mcp-client/src/invariant.ts | 27 ++- .../sandbox/sandbox-local/src/invariant.ts | 26 +- packages/sandbox/sandbox/src/invariant.ts | 20 +- packages/sdk/create-sdk/src/invariant.ts | 32 ++- packages/sdk/helper/src/invariant.ts | 28 ++- packages/sdk/scripts/src/args.ts | 5 +- packages/sdk/scripts/src/forwarding.ts | 21 ++ packages/sdk/scripts/src/invariant.ts | 30 ++- packages/sdk/scripts/tests/scripts.spec.ts | 1 + .../src/invariant.ts | 26 +- .../src/invariant.ts | 26 +- .../session-persistence/src/invariant.ts | 17 +- .../session-query/src/invariant.ts | 29 ++- packages/skill/skill-local/src/invariant.ts | 26 +- packages/skill/skill/src/invariant.ts | 26 +- packages/skill/tool-skill/src/invariant.ts | 28 ++- packages/spill/spill-local/src/invariant.ts | 26 +- packages/spill/spill-policy/src/invariant.ts | 30 ++- .../spill-policy/tests/spill-policy.spec.ts | 11 + packages/spill/spill/src/invariant.ts | 20 +- .../subagent/subagent-acp/src/invariant.ts | 26 +- .../subagent/subagent-fork/src/invariant.ts | 26 +- .../subagent-inprocess/src/invariant.ts | 23 +- .../src/structured-protocol.ts | 10 + .../subagent-inprocess/src/structured.ts | 14 +- .../subagent/subagent-spawn/src/invariant.ts | 26 +- .../subagent-subprocess/src/invariant.ts | 28 ++- packages/subagent/subagent/src/invariant.ts | 26 +- .../subagent/tool-subagent/src/invariant.ts | 28 ++- .../support/acp-snapshot/src/invariant.ts | 33 ++- .../support/agent-loop-testkit/src/index.ts | 18 +- .../agent-loop-testkit/src/invariant.ts | 24 +- packages/support/invariants/README.md | 19 +- packages/support/invariants/src/index.ts | 176 +++++++++++++- packages/support/invariants/src/invariant.ts | 27 ++- .../support/invariants/tests/service.spec.ts | 222 +++++++++++++++++- packages/support/llm-replay/src/invariant.ts | 29 ++- .../support/loader-smoke/src/invariant.ts | 31 ++- packages/tasks/tasks/src/invariant.ts | 27 ++- packages/tasks/tool-tasks/src/invariant.ts | 28 ++- .../timeout/timeout-policy/src/invariant.ts | 26 +- packages/todo/tool-todo/src/invariant.ts | 26 +- packages/ui/acp/src/invariant.ts | 33 ++- packages/ui/app-boot/src/config-path.ts | 23 ++ packages/ui/app-boot/src/index.ts | 21 +- packages/ui/app-boot/src/invariant.ts | 27 ++- packages/ui/jsonrpc/src/invariant.ts | 26 +- packages/ui/permission/src/invariant.ts | 30 ++- .../ui/permission/tests/permission.spec.ts | 7 +- packages/ui/stdio/src/invariant.ts | 28 ++- packages/ui/tool-ask-user/src/invariant.ts | 27 ++- packages/ui/tui/src/invariant.ts | 29 ++- packages/ui/tui/tests/tui.snapshot.ts | 2 +- packages/ui/user-approval/src/invariant.ts | 27 ++- packages/ui/user-interaction/src/invariant.ts | 26 +- packages/util/brand/src/invariant.ts | 21 +- packages/util/home/src/invariant.ts | 26 +- packages/util/paths/src/invariant.ts | 27 ++- packages/util/retention/src/invariant.ts | 32 ++- packages/util/timeout/src/invariant.ts | 27 ++- packages/web/tool-web/src/invariant.ts | 28 ++- packages/web/web-fetch-local/src/invariant.ts | 26 +- .../web/web-search-deepseek/src/invariant.ts | 23 +- packages/web/web-search-exa/src/invariant.ts | 26 +- .../web-search-perplexity/src/invariant.ts | 23 +- packages/web/web/src/invariant.ts | 26 +- .../workflow/tool-workflow/src/invariant.ts | 28 ++- .../workflow-workerthread/src/invariant.ts | 26 +- packages/workflow/workflow/src/invariant.ts | 20 +- scripts/gen-package-invariants.ts | 44 ---- scripts/package-invariants.spec.ts | 112 ++++++++- scripts/package-invariants.ts | 150 ++++++++---- scripts/verify-package-invariants.ts | 21 ++ website/zh-CN/api/harness/invariants.md | 4 +- 125 files changed, 2317 insertions(+), 1161 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md create mode 100644 docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md create mode 100644 packages/hooks/hooks-claude/tests/invariant.spec.ts create mode 100644 packages/hooks/hooks-codex/tests/invariant.spec.ts create mode 100644 packages/sdk/scripts/src/forwarding.ts create mode 100644 packages/subagent/subagent-inprocess/src/structured-protocol.ts create mode 100644 packages/ui/app-boot/src/config-path.ts delete mode 100644 scripts/gen-package-invariants.ts create mode 100644 scripts/verify-package-invariants.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4ea9c7a300..76663117b8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -396,7 +396,7 @@ export interface Config { } ``` -Source: [`packages/support/invariants/src/index.ts:16`](../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:15`](../packages/support/invariants/src/index.ts) ## `@deepseek-ai/dsh-jsonrpc` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ca5eb4232b..7da704df75 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -495,7 +495,7 @@ Package-owned invariant registry with global and regex-based selection. register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:95`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:261`](../../packages/support/invariants/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 29f8763460..b1177e74a5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -55,6 +55,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `internal/dispatch` | - | [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| `internal/status` | - | [`agent`](../packages/core/agent) | +| `internal/plugin` | - | [`invariants`](../packages/support/invariants) | +| `internal/service` | - | [`invariants`](../packages/support/invariants) | +| `internal/status` | - | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index c7e542276e..e4ee5fab8d 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -167,6 +167,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Initiating Agent scope over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-initiator-scope.md) | 2026-07-15 | | [Advisory LLM catalogs and per-session ACP model selection](implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) | 2026-07-15 | | [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 | +| [Executable package invariant contracts](implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md) | 2026-07-19 | | [Package-owned invariant service seam](implemented/architecture/2026-07-19-package-owned-invariant-service.md) | 2026-07-19 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml new file mode 100644 index 0000000000..f234facc6e --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-package-invariant-runtime-contracts.md: 57a768f2e3cc954d02f74a9dca46680e59a06403 +2026-07-19-package-invariant-runtime-contracts.zh.md: 7a289da17414cc1fcac3109b4a96b2f047b16a89 diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md new file mode 100644 index 0000000000..57a768f2e3 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -0,0 +1,65 @@ +# RFC: Executable package invariant contracts + +Status: implemented + +English | [中文](2026-07-19-package-invariant-runtime-contracts.zh.md) + +## Problem + +The package-owned invariant seam made registration and publication exhaustive, but its generated baseline treated package-name ownership as sufficient. An empty installer could satisfy the repository gate while observing no runtime state and rejecting no invalid state. That made the exhaustive count a wiring claim rather than protection for the package contract. + +Every package shape cannot use the same invariant. Cordis plugins own fibers, injections, effects, and services; service seams admit structural third-party implementations; stateful domains need event relations; pure libraries and bin packages expose algebra, parsing, normalization, or entrypoint constraints. The repository needs one enforceable obligation without moving those contracts back into a central product-aware package. + +Vitest also mounts every companion globally. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. + +## Decision + +### Every companion executes a package contract + +Every workspace package keeps its separately published `./invariant` companion and exact npm-name registration, but the installer must execute at least one package-specific check through the bound `fail(message)` reporter. The ownership-baseline generator and its root script entry are removed; generated markers, empty installers, and installers that never reference the reporter are repository errors. + +The implemented contracts use four forms: + +| Owner shape | Runtime contract | +|---|---| +| Stateful session, agent, scope, and agent-loop owners | Validate event ordering, enclosure, status transitions, scoped subjects, and reconstructable model requests. | +| Cordis plugin owners | Validate the plugin's own declared runtime name, required injections, owned effects, provided services, and package-specific all-or-none or config-dependent relations. | +| Cordis service seams | Validate the structural method and descriptor surface of current and future implementations. | +| Pure libraries, bins, and support packages | Validate stable parser mapping, protocol precedence, retention and timeout algebra, path resolution, normalization, environment scrubbing, or deliberately empty runtime entrypoints. | + +At implementation time this covers all 90 workspace packages: four stateful companions, 62 plugin-fiber companions, eight service-shape companions, and 16 pure/bin/support companions. + +### Product-independent observers + +`observePluginInvariant` checks existing fibers immediately and future active fibers through global Cordis lifecycle events. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. + +`observeServiceInvariant` checks the current service and every later binding. `serviceShapeViolation` validates callable members and non-empty string descriptors structurally instead of using `instanceof`, so conforming third-party backends and complete test doubles remain valid while incomplete stand-ins fail. + +`assertInvariant` handles synchronous package algebra. Pure-package companions register an asynchronous child effect and dynamically import their owner inside that effect. This preserves atomic service-owned rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. + +### Gate and test execution + +`verify-package-invariants` discovers every workspace package and retains the publication checks for the exact registration name, `./invariant` export, published files, invariant peer and development dependencies, TypeScript reference, and bundle entry. Its source check additionally parses the local `install` function, rejects a generated marker or empty body, requires a second failure-reporter parameter and its use, and rejects duplicate name-based plugin observers across packages. These AST checks are a minimum acceptance rule, not a claim that source shape proves semantic quality. + +The Vitest setup host mounts `InvariantService` with `{ enabled: true }` and all 90 companions before an ordinary Cordis root's first plugin. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. Focused selection, lifecycle, and four stateful-owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. + +Helper tests reject invalid plugin names, missing injections, effects, services, custom relations, malformed service shapes, and failed assertions. Package suites then activate real plugins across their existing config and HMR paths. Test-only service stand-ins must implement the complete checked seam rather than bypass global invariants. + +## Alternatives considered + +- **Keep generated ownership-only companions.** Rejected because registration without an executable assertion cannot reject a broken package and makes the exhaustive gate misleading. +- **Generate one synthetic assertion into every package.** Rejected because a universal assertion would again optimize for satisfying the gate instead of protecting an owner-specific contract. +- **Move the per-package contract matrix into `dsh-invariants`.** Rejected because product imports, vocabulary, and change ownership would return to the central service. +- **Import every owner entrypoint statically from its companion.** Rejected because the global test host would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. +- **Require first-party service-class identity.** Rejected because service seams are structural extension boundaries; `instanceof` would reject valid external implementations and test doubles. +- **Register invariants implicitly from package root entrypoints.** Rejected for the composition-order and hidden-effect reasons in the package-owned service RFC. + +## Consequences + +- Every package contributes an executable check; adding a package without one fails the top-level gate. +- The invariant service remains product-independent while providing reusable lifecycle and shape observers. +- Ordinary unit, snapshot, and e2e tests run with global invariant enablement and every companion registered. +- Plugin names used for name-based observation must be unique within one Cordis root; packages may opt into exact callback identity when safe. +- Pure-package checks sample stable startup contracts. Mutable behavior must use an event, service, or plugin-fiber observer. +- More companion work runs during tests and selected deployments, trading small startup cost for immediate package-attributed failures. +- The original regex selection, blocklist precedence, registration uniqueness, rollback, disposal, and HMR contracts remain unchanged. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md new file mode 100644 index 0000000000..7a289da174 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -0,0 +1,65 @@ +# RFC: 可执行的包不变式契约 + +Status: implemented + +[English](2026-07-19-package-invariant-runtime-contracts.md) | 中文 + +## 问题 + +包拥有的不变式接缝让注册与发布覆盖完整,但生成的基线把包名所有权视为充分条件。空 installer 可以通过仓库门禁,却不观察任何运行时状态,也不拒绝任何无效状态。这样一来,完整计数只能证明接线存在,不能保护包契约。 + +不同包形态不能使用同一种不变式。Cordis 插件拥有 fiber、注入、effect 与服务;服务接缝允许结构兼容的第三方实现;有状态领域需要事件关系;纯库和 bin 包暴露代数、解析、规范化或入口约束。仓库需要一个可执行的统一义务,同时不能把这些契约重新移回了解产品语义的中央包。 + +Vitest 还会全局挂载每个伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 + +## 决策 + +### 每个伴随插件都执行包契约 + +每个工作区包保留独立发布的 `./invariant` 伴随插件和准确 npm 包名注册,但 installer 必须通过绑定的 `fail(message)` 报告器执行至少一个包专属检查。删除所有权基线生成器及其根脚本入口;生成标记、空 installer 和从不引用报告器的 installer 都属于仓库错误。 + +实现后的契约采用四种形态: + +| 所有者形态 | 运行时契约 | +|---|---| +| 有状态的 session、agent、scope 与 agent-loop 所有者 | 验证事件顺序、包围关系、状态转换、作用域主体和可重建的模型请求。 | +| Cordis 插件所有者 | 验证插件自身声明的运行时名称、必要注入、拥有的 effect、提供的服务,以及包专属的全有或全无关系或配置依赖关系。 | +| Cordis 服务接缝 | 验证当前和未来实现的结构化方法与描述字段表面。 | +| 纯库、bin 与支持包 | 验证稳定的解析映射、协议优先级、保留与超时代数、路径解析、规范化、环境清理或刻意为空的运行时入口。 | + +实现时覆盖全部 90 个工作区包:四个有状态伴随插件、62 个插件 fiber 伴随插件、八个服务形状伴随插件和 16 个纯库、bin 或支持包伴随插件。 + +### 与产品无关的观察器 + +`observePluginInvariant` 会立即检查已有 fiber,并通过全局 Cordis 生命周期事件检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 + +`observeServiceInvariant` 检查当前服务及之后的每次绑定。`serviceShapeViolation` 以结构方式验证可调用成员和非空字符串描述字段,而不使用 `instanceof`;因此符合契约的第三方后端和完整测试替身有效,不完整替身会失败。 + +`assertInvariant` 处理同步包代数。纯包伴随插件注册异步子 effect,并在该 effect 内动态导入所有者。这样既保留服务拥有的原子回滚,又允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 + +### 门禁与测试执行 + +`verify-package-invariants` 发现每个工作区包,并保留准确注册名、`./invariant` export、发布文件、不变式 peer 与开发依赖、TypeScript 引用和 bundle 入口的发布检查。源码检查还会解析本地 `install` 函数,拒绝生成标记或空函数体,要求第二个失败报告器参数及其使用,并拒绝跨包重复的按名称插件观察器。这些 AST 检查只是最低接收规则,并不宣称源码形状足以证明语义质量。 + +Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 90 个伴随插件,然后才启动普通 Cordis 根上下文的第一个插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。选择、生命周期和四个有状态所有者的聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 + +辅助测试会拒绝错误插件名、缺失注入、effect、服务或自定义关系、错误服务形状和失败断言。随后,包套件在已有配置与 HMR 路径上激活真实插件。测试专用服务替身必须实现完整的已检查接缝,不能绕过全局不变式。 + +## 考虑过的替代方案 + +- **保留生成的仅声明所有权伴随插件。** 不予采纳,因为没有可执行断言的注册无法拒绝损坏的包,也会让完整门禁产生误导。 +- **为每个包生成一个合成断言。** 不予采纳,因为通用断言仍是在优化如何通过门禁,而不是保护所有者专属契约。 +- **把逐包契约矩阵移入 `dsh-invariants`。** 不予采纳,因为产品导入、词汇和变更所有权会重新回到中央服务。 +- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为全局测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 +- **要求第一方服务类身份。** 不予采纳,因为服务接缝是结构化扩展边界;`instanceof` 会拒绝有效的外部实现和测试替身。 +- **从包根入口隐式注册不变式。** 因包拥有服务 RFC 中的组合顺序与隐藏 effect 问题而不予采纳。 + +## 后果 + +- 每个包都贡献可执行检查;新增包若没有检查,会在顶层门禁失败。 +- 不变式服务保持与产品无关,同时提供可复用的生命周期与形状观察器。 +- 普通单元、snapshot 与 e2e 测试均全局启用不变式并注册每个伴随插件。 +- 用于按名称观察的插件名在一个 Cordis 根上下文内必须唯一;安全时包可以选择准确 callback 身份。 +- 纯包检查对稳定启动契约采样;可变行为必须使用事件、服务或插件 fiber 观察器。 +- 测试和被选部署会执行更多伴随工作,以少量启动成本换取即时且带包归属的失败。 +- 原有正则选择、blocklist 优先级、注册唯一性、回滚、dispose 与 HMR 契约保持不变。 diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index dfe64b7110..c3d4aa812a 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: bf27bb4e951988bc124509b49dea9b5509f8e6f3 -2026-07-19-package-owned-invariant-service.zh.md: c9dfbb65bf4fd497f2f592f7741923dcef3e51fb +2026-07-19-package-owned-invariant-service.md: 32a5a0798c4121be01ae1a51ddb4e864a2663de5 +2026-07-19-package-owned-invariant-service.zh.md: 9ebeda7575452fd29245bffb1354e56e596f711e diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md index bf27bb4e95..32a5a0798c 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -18,7 +18,7 @@ Package ownership must also be exhaustive. Without a mechanical repository rule, `@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. -Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A package with no relational check uses a generated ownership-only installer: it reserves the name through the real service boundary but installs no listeners. Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. +Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name and installs an executable package-specific contract. Generated ownership-only installers are forbidden by the follow-up [runtime-contract RFC](2026-07-19-package-invariant-runtime-contracts.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. ### Configuration and selection @@ -64,9 +64,9 @@ The former functional-plugin entrypoint and one-argument `InvariantError` constr | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -These four owners contain stateful checks and focused tests. Every other package carries a generated baseline companion until it gains a relational assertion. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. +These four owners contain stateful checks and focused tests. Other owners check their plugin fibers and effects, structural service implementations, or stable pure-library algebra. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. -`verify-package-invariants` discovers every workspace package and rejects missing or stale companion source, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. The generator writes only missing or marked ownership baselines, so a package-owned implementation is never replaced. +`verify-package-invariants` discovers every workspace package and rejects missing companion source, generated markers, empty or reporter-free installers, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. ### Scoped-event semantic map @@ -96,7 +96,7 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s ## Consequences - Product packages own and test their relational assertions while the service stays product-independent. -- Every package pays the small publication and dependency cost of an invariant companion, including packages whose generated baseline currently installs no listeners. +- Every package pays the publication, dependency, and runtime-check cost of an executable invariant companion. - Standard compositions can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. diff --git a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index c9dfbb65bf..9ebeda7575 100644 --- a/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -18,7 +18,7 @@ Status: implemented `@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 -工作区内的每个包都发布 `./invariant` 伴随插件,并注册自己完整且准确的 npm 包名。没有关系检查的包使用生成的仅声明所有权 installer:它通过真实服务边界占用包名,但不安装监听器。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 +工作区内的每个包都发布 `./invariant` 伴随插件,注册自己完整且准确的 npm 包名,并安装可执行的包专属契约。后续的[运行时契约 RFC](2026-07-19-package-invariant-runtime-contracts.md) 禁止生成的仅声明所有权 installer。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 ### 配置与选择 @@ -64,9 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -这四个所有者保存有状态检查与聚焦测试。其他每个包在获得关系断言之前,都带有生成的基线伴随插件。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 +这四个所有者保存有状态检查与聚焦测试。其他所有者检查自己的插件 fiber 与 effect、结构化服务实现或稳定的纯库代数。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 -`verify-package-invariants` 会发现每个工作区包,并拒绝缺失或陈旧的伴随插件源码、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。生成器只写入缺失或带生成标记的所有权基线,因此绝不会替换包自行维护的实现。 +`verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、空 installer、不使用失败报告器的 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。 ### Scoped event 语义映射 @@ -96,7 +96,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 ## 后果 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 -- 每个包都要承担不变式伴随插件带来的少量发布与依赖成本,包括目前只安装生成基线、不添加监听器的包。 +- 每个包都要承担可执行不变式伴随插件带来的发布、依赖与运行时检查成本。 - 标准组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 diff --git a/package.json b/package.json index abf1e59178..f62e92f754 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,7 @@ "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", - "gen-package-invariants": "tsx scripts/gen-package-invariants.ts", - "verify-package-invariants": "tsx scripts/gen-package-invariants.ts --check", + "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 1735c44978..b5fcf47273 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,7 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns an invariant companion.** Publish `./invariant`, register the manifest's exact npm name, and keep the generated ownership baseline until relational checks exist. `verify-package-invariants` gates source and publication wiring ([rationale](../docs/rfc/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). +- **Every package owns executable invariants.** Publish `./invariant`, register its exact name, and enforce a package runtime contract with the bound reporter; generated, empty, and reporter-free installers fail `verify-package-invariants` ([rationale](../docs/rfc/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md)). Naming notes: diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts index 8af5f91b22..397bf0d6dc 100644 --- a/packages/bash/bash-local/src/invariant.ts +++ b/packages/bash/bash-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-bash-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-bash-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash-local`. @module @deepseek-ai/dsh-bash-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local' @@ -17,8 +10,19 @@ export const name = 'bash-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LocalBashExecutor', + effects: [ + 'ctx.provide("bash")', + 'local bash teardown', + ], + services: [ + 'bash', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts index e74190fa82..f758c90f6f 100644 --- a/packages/bash/bash-sandbox/src/invariant.ts +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-bash-sandbox`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-bash-sandbox/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash-sandbox`. @module @deepseek-ai/dsh-bash-sandbox/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox' @@ -17,8 +10,21 @@ export const name = 'bash-sandbox-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SandboxBashExecutor', + inject: [ + 'sandbox', + ], + effects: [ + 'ctx.provide("bash")', + ], + services: [ + 'bash', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts index 350f68bfc7..91f135b1bb 100644 --- a/packages/bash/bash/src/invariant.ts +++ b/packages/bash/bash/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-bash`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-bash/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash`. @module @deepseek-ai/dsh-bash/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash' @@ -17,8 +10,12 @@ export const name = 'bash-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'bash', value => serviceShapeViolation(value, { + methods: ['resolve', 'run', 'start'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts index 286089c7ff..ca63f9ff77 100644 --- a/packages/bash/tool-bash/src/invariant.ts +++ b/packages/bash/tool-bash/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-bash`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-bash/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-bash`. @module @deepseek-ai/dsh-tool-bash/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash' @@ -17,8 +10,25 @@ export const name = 'tool-bash-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-bash', + inject: [ + 'tools', + 'bash', + 'systemPrompt', + ], + effects: [ + 'ctx.provide("bashEnv")', + 'bashEnv.register()', + 'tools.register()', + ], + services: [ + 'bashEnv', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +37,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts index 41b3eab511..63daf58ae9 100644 --- a/packages/code-runtime/code-runtime-worker/src/invariant.ts +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-code-runtime-worker`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-code-runtime-worker`. * @module @deepseek-ai/dsh-code-runtime-worker/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker' @@ -17,8 +13,19 @@ export const name = 'code-runtime-worker-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'WorkerCodeRuntime', + effects: [ + 'ctx.provide("codeRuntime")', + 'worker code-runtime teardown', + ], + services: [ + 'codeRuntime', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +34,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts index 6102927d77..1ee564c60b 100644 --- a/packages/code-runtime/code-runtime/src/invariant.ts +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-code-runtime`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-code-runtime/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-code-runtime`. @module @deepseek-ai/dsh-code-runtime/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime' @@ -17,8 +10,20 @@ export const name = 'code-runtime-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'codeRuntime', (value) => { + const violation = serviceShapeViolation(value, { + methods: ['run'], + stringProperties: ['language', 'isolation'], + }) + if (violation !== undefined) return violation + const service = value as { language: string; isolation: string } + return /^[a-z][a-z0-9-]*$/.test(service.language) && /^[a-z][a-z0-9-]*$/.test(service.isolation) + ? undefined + : 'code runtime language and isolation must be lowercase identifiers' + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 7811ef0531..01736ea0ab 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -84,4 +84,18 @@ describe('CodeRuntime service seam', () => { const { ctx } = await setup() await expect(ctx.plugin(StubRuntime)).rejects.toThrow(/registered/) }) + + it.each([ + [{ language: 'typescript', isolation: 'worker' }, /must expose method "run"/], + [{ language: 'TypeScript', isolation: 'worker', run() {} }, /must be lowercase identifiers/], + ])('rejects an invalid runtime implementation through the package invariant', async (value, message) => { + const ctx = new Context() + const invalidRuntime = { + name: 'invalid-code-runtime', + apply(child: Context) { + child.provide('codeRuntime', value as unknown as CodeRuntime) + }, + } + await expect(ctx.plugin(invalidRuntime)).rejects.toThrow(message) + }) }) diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts index f72c0e5898..3e034a2f58 100644 --- a/packages/compact/compact-basic/src/invariant.ts +++ b/packages/compact/compact-basic/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-compact-basic`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-compact-basic/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-compact-basic`. @module @deepseek-ai/dsh-compact-basic/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact-basic' @@ -17,8 +10,37 @@ export const name = 'compact-basic-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'BasicCompactService', + inject: [ + 'llm', + 'tokenMeter', + ], + effects: [ + 'ctx.provide("compact")', + ], + services: [ + 'compact', + ], + validate: (fiber, effectLabels) => { + const automaticEffects = [ + 'ctx.on("agent/post-step")', + 'ctx.on("agent/request-error")', + ] + const installed = automaticEffects.filter(label => effectLabels.has(label)).length + const automatic = (fiber.config as { auto?: boolean }).auto !== false + if (automatic && installed !== automaticEffects.length) { + return 'automatic compaction must install both pressure and overflow listeners' + } + if (!automatic && installed !== 0) { + return 'auto:false must install neither automatic compaction listener' + } + return undefined + }, + }) +} /** * Register this package's invariant companion. @@ -27,4 +49,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 31992793f0..762289c9d9 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -5,7 +5,7 @@ import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' -import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import type { CompactService, CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -198,6 +198,29 @@ describe('compact configuration and defaults', () => { expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern) } }) + + it.each([ + [{ auto: true }, false, /must install both pressure and overflow listeners/], + [{ auto: false }, true, /must install neither automatic compaction listener/], + ])('rejects an inconsistent automatic-listener topology through the package invariant', async (config, installListener, message) => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(TokenMeterService) + const invalidCompact = { + name: 'BasicCompactService', + inject: ['llm', 'tokenMeter'], + apply(child: Context, _config: { auto?: boolean }) { + child.provide('compact', { + compactIfNeeded() {}, + compactRegion() {}, + } as unknown as CompactService) + if (installListener) { + child.effect(() => () => {}, 'ctx.on("agent/post-step")') + } + }, + } + await expect(ctx.plugin(invalidCompact, config)).rejects.toThrow(message) + }) }) describe('pressure measurement and retention', () => { diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index f7a2cbffd1..974f7a29ca 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-compact`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-compact/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-compact`. @module @deepseek-ai/dsh-compact/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact' @@ -17,8 +10,12 @@ export const name = 'compact-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'compact', value => serviceShapeViolation(value, { + methods: ['compactIfNeeded', 'compactRegion'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 64fb98ac81..f1497f4c4a 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-time-context`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-time-context/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-time-context`. @module @deepseek-ai/dsh-time-context/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-time-context' @@ -17,8 +10,18 @@ export const name = 'time-context-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'time-context', + inject: [ + 'agents', + ], + effects: [ + 'ctx.on("agent/pre-step")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index f3e76225c0..0258acff14 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-workspace-context`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-workspace-context/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-workspace-context`. @module @deepseek-ai/dsh-workspace-context/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context' @@ -17,8 +10,18 @@ export const name = 'workspace-context-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'workspace-context', + effects: [ + 'ctx.on("session/event")', + 'ctx.on("agent/session-prefix")', + 'ctx.on("tools/post-execute")', + 'ctx.on("tools/result")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/cordis/tool-cordis/src/invariant.ts b/packages/cordis/tool-cordis/src/invariant.ts index a3f375b69f..8d09b3bb9e 100644 --- a/packages/cordis/tool-cordis/src/invariant.ts +++ b/packages/cordis/tool-cordis/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-cordis`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-cordis/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-cordis`. @module @deepseek-ai/dsh-tool-cordis/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis' @@ -17,8 +10,19 @@ export const name = 'tool-cordis-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-cordis', + inject: [ + 'tools', + ], + effects: [ + 'ctx.plugin()', + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts index 5117f93ce0..36052bb90a 100644 --- a/packages/core/system-prompt/src/invariant.ts +++ b/packages/core/system-prompt/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-system-prompt`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-system-prompt/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-system-prompt`. @module @deepseek-ai/dsh-system-prompt/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-system-prompt' @@ -17,8 +10,19 @@ export const name = 'system-prompt-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SystemPrompt', + effects: [ + 'ctx.provide("systemPrompt")', + 'systemPrompt.section()', + ], + services: [ + 'systemPrompt', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 7c5743e1d4..c5b53e67fa 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tools`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tools/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tools`. @module @deepseek-ai/dsh-tools/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tools' @@ -17,8 +10,22 @@ export const name = 'tools-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'ToolRegistry', + inject: [ + 'systemPrompt', + ], + effects: [ + 'ctx.provide("tools")', + 'systemPrompt.tools()', + ], + services: [ + 'tools', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +34,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts index d8f2ff17dc..b8801dd71e 100644 --- a/packages/examples/acp-demo/src/invariant.ts +++ b/packages/examples/acp-demo/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-acp-demo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-acp-demo/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-acp-demo`. @module @deepseek-ai/dsh-acp-demo/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-demo' @@ -17,8 +10,15 @@ export const name = 'acp-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'acp-demo', + effects: [ + 'ctx.plugin()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 10ee749f8c..d8d6f85db5 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -21,7 +21,14 @@ import * as acpAgent from '../src/index.ts' */ async function mount(config: acpAgent.Config, withBash = false): Promise { const ctx = new Context() - if (withBash) ctx.provide('bash', { sandboxMode: undefined }) + if (withBash) { + ctx.provide('bash', { + sandboxMode: undefined, + resolve() { throw new Error('composition test does not execute bash') }, + run() { throw new Error('composition test does not execute bash') }, + start() { throw new Error('composition test does not execute bash') }, + }) + } await ctx.plugin(acpAgent, config) // The bundle mounts its children inside apply() (not awaited there); let their // fibers settle so the spine services are ready. diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts index 913b5ca2ab..75ab162567 100644 --- a/packages/examples/agent-spine-demo/src/invariant.ts +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-agent-spine-demo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-agent-spine-demo/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-agent-spine-demo`. @module @deepseek-ai/dsh-agent-spine-demo/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-spine-demo' @@ -17,8 +10,15 @@ export const name = 'agent-spine-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'agent-spine-demo', + effects: [ + 'ctx.plugin()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 555a675cba..37bde87b31 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -48,7 +48,14 @@ async function mount(config: agentCore.Config, withBash = false): Promise {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'cli-demo', + effects: [ + 'ctx.plugin()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 343d6184d7..4e55f387fa 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -22,7 +22,14 @@ async function skillConfig(catalogDescriptionMaxLength?: number): Promise { const ctx = new Context() - if (withBash) ctx.provide('bash', { sandboxMode: undefined }) + if (withBash) { + ctx.provide('bash', { + sandboxMode: undefined, + resolve() { throw new Error('composition test does not execute bash') }, + run() { throw new Error('composition test does not execute bash') }, + start() { throw new Error('composition test does not execute bash') }, + }) + } contexts.push(ctx) await ctx.plugin(cliDemo, config) await new Promise(resolve => setTimeout(resolve, 80)) diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index 21f19faf0f..f65eee8165 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-jsonrpc-demo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-jsonrpc-demo/invariant - */ +/** Package-owned runtime contract for @deepseek-ai/dsh-jsonrpc-demo. @module @deepseek-ai/dsh-jsonrpc-demo/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc-demo' @@ -17,8 +11,15 @@ export const name = 'jsonrpc-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert that Loader configuration, rather than a hidden root plugin, owns composition. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const packageEntry = await import('./index.ts') + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') + return () => {} + }, 'jsonrpc-demo: validate bin-only entrypoint') +} /** * Register this package's invariant companion. diff --git a/packages/examples/stdio-demo/src/invariant.ts b/packages/examples/stdio-demo/src/invariant.ts index 5d51c3cd9b..8e24a3b94b 100644 --- a/packages/examples/stdio-demo/src/invariant.ts +++ b/packages/examples/stdio-demo/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio-demo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-stdio-demo/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-stdio-demo`. @module @deepseek-ai/dsh-stdio-demo/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-stdio-demo' @@ -17,8 +10,15 @@ export const name = 'stdio-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'stdio-demo', + effects: [ + 'ctx.plugin()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index c8ca063151..7bd309a916 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -18,7 +18,14 @@ import * as stdioAgent from '../src/index.ts' */ async function mount(config: stdioAgent.Config, withBash = false): Promise { const ctx = new Context() - if (withBash) ctx.provide('bash', { sandboxMode: undefined }) + if (withBash) { + ctx.provide('bash', { + sandboxMode: undefined, + resolve() { throw new Error('composition test does not execute bash') }, + run() { throw new Error('composition test does not execute bash') }, + start() { throw new Error('composition test does not execute bash') }, + }) + } await ctx.plugin(stdioAgent, config) // The app mounts its children inside apply() (not awaited there); let their // fibers settle so the spine services + the pre-created agent are ready. diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts index 0c89f2299b..6469bffae6 100644 --- a/packages/fs/fs-local/src/invariant.ts +++ b/packages/fs/fs-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-fs-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-fs-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs-local`. @module @deepseek-ai/dsh-fs-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-local' @@ -17,8 +10,18 @@ export const name = 'fs-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LocalFileSystem', + effects: [ + 'ctx.provide("fs")', + ], + services: [ + 'fs', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts index 11f155d2ec..de77ac303d 100644 --- a/packages/fs/fs-policy/src/invariant.ts +++ b/packages/fs/fs-policy/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-fs-policy`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-fs-policy/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs-policy`. @module @deepseek-ai/dsh-fs-policy/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-policy' @@ -17,8 +10,17 @@ export const name = 'fs-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'fs-policy', + effects: [ + 'ctx.on("fs/write-intent")', + 'ctx.on("fs/edit-intent")', + 'ctx.on("fs/observed")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +29,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts index 55895b4b24..586f962247 100644 --- a/packages/fs/fs/src/invariant.ts +++ b/packages/fs/fs/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-fs`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-fs/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs`. @module @deepseek-ai/dsh-fs/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs' @@ -17,8 +10,12 @@ export const name = 'fs-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'fs', value => serviceShapeViolation(value, { + methods: ['resolve', 'stat', 'lstat', 'readText', 'streamText', 'listDir', 'writeText', 'editText'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts index f0055b611a..65da611560 100644 --- a/packages/fs/tool-fs-search/src/invariant.ts +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-fs-search`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-fs-search/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-fs-search`. @module @deepseek-ai/dsh-tool-fs-search/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs-search' @@ -17,8 +10,20 @@ export const name = 'tool-fs-search-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-fs-search', + inject: [ + 'tools', + 'systemPrompt', + 'bash', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts index 7a683d978f..caa31a13b5 100644 --- a/packages/fs/tool-fs/src/invariant.ts +++ b/packages/fs/tool-fs/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-fs`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-fs/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-fs`. @module @deepseek-ai/dsh-tool-fs/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs' @@ -17,8 +10,20 @@ export const name = 'tool-fs-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-fs', + inject: [ + 'tools', + 'fs', + 'systemPrompt', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts index 05c4df1a66..6bbdd1b539 100644 --- a/packages/guard/repeat-tool-guard/src/invariant.ts +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-repeat-tool-guard`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-repeat-tool-guard/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-repeat-tool-guard`. @module @deepseek-ai/dsh-repeat-tool-guard/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-repeat-tool-guard' @@ -17,8 +10,16 @@ export const name = 'repeat-tool-guard-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'repeat-tool-guard', + effects: [ + 'ctx.on("tools/post-execute")', + 'ctx.on("agent/prompt-submit")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +28,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 9893493644..6503791f1c 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-hook-protocol`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-hook-protocol/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-hook-protocol. @module @deepseek-ai/dsh-hook-protocol/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hook-protocol' @@ -17,8 +11,24 @@ export const name = 'hook-protocol-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert blocking-exit decoding and restrictive merge precedence. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { parseHookOutput } = await import('./codec.ts') + const { mergeHookOutputs } = await import('./merge.ts') + const blocked = parseHookOutput(2, '', ' denied ') + assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', + 'exit 2 must decode as a block whose reason is trimmed stderr') + + const merged = mergeHookOutputs([ + { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, + { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, + ]) + assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', + 'deny must override allow and retain only the winning decision reason') + return () => {} + }, 'hook-protocol: validate decode and merge algebra') +} /** * Register this package's invariant companion. diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts index 5c6002f7f5..d092d78a00 100644 --- a/packages/hooks/hooks-claude/src/invariant.ts +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-hooks-claude`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-hooks-claude/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-hooks-claude`. @module @deepseek-ai/dsh-hooks-claude/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude' @@ -17,8 +10,31 @@ export const name = 'hooks-claude-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'hooks-claude', + inject: [ + 'bash', + ], + validate: (_fiber, effectLabels) => { + const hookEffects = [ + 'hooks-claude: drain detached hook runs', + 'ctx.on("agent/session-start")', + 'ctx.on("agent/prompt-submit")', + 'ctx.on("tools/pre-execute")', + 'ctx.on("tools/post-execute")', + 'ctx.on("agent/turn-continuation")', + 'ctx.on("subagent/start")', + 'ctx.on("subagent/end")', + ] + const installed = hookEffects.filter(label => effectLabels.has(label)).length + return installed === 0 || installed === hookEffects.length + ? undefined + : 'a readable Claude hook config must install its complete listener set atomically' + }, + }) +} /** * Register this package's invariant companion. @@ -27,4 +43,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-claude/tests/invariant.spec.ts b/packages/hooks/hooks-claude/tests/invariant.spec.ts new file mode 100644 index 0000000000..1f7513cc64 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/invariant.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { BashExecutor } from '@deepseek-ai/dsh-bash' + +describe('Claude hook package invariant', () => { + it('rejects a partially installed hook listener set', async () => { + const ctx = new Context() + await ctx.plugin({ + name: 'claude-invariant-bash', + apply(child: Context) { + child.provide('bash', { + resolve() {}, + async run() {}, + start() {}, + } as unknown as BashExecutor) + }, + }) + await expect(ctx.plugin({ + name: 'hooks-claude', + inject: ['bash'], + apply(child: Context) { + child.effect(() => () => {}, 'ctx.on("agent/session-start")') + }, + })).rejects.toThrow(/must install its complete listener set atomically/) + }) +}) diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts index 1b8f03a057..4fba6cea2d 100644 --- a/packages/hooks/hooks-codex/src/invariant.ts +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-hooks-codex`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-hooks-codex/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-hooks-codex`. @module @deepseek-ai/dsh-hooks-codex/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-codex' @@ -17,8 +10,29 @@ export const name = 'hooks-codex-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'hooks-codex', + inject: [ + 'bash', + ], + validate: (_fiber, effectLabels) => { + const hookEffects = [ + 'hooks-codex: drain detached hook runs', + 'ctx.on("agent/session-start")', + 'ctx.on("agent/prompt-submit")', + 'ctx.on("tools/pre-execute")', + 'ctx.on("tools/post-execute")', + 'ctx.on("agent/turn-continuation")', + ] + const installed = hookEffects.filter(label => effectLabels.has(label)).length + return installed === 0 || installed === hookEffects.length + ? undefined + : 'a readable Codex hook config must install its complete listener set atomically' + }, + }) +} /** * Register this package's invariant companion. @@ -27,4 +41,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-codex/tests/invariant.spec.ts b/packages/hooks/hooks-codex/tests/invariant.spec.ts new file mode 100644 index 0000000000..bd6ee595f9 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/invariant.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { BashExecutor } from '@deepseek-ai/dsh-bash' + +describe('Codex hook package invariant', () => { + it('rejects a partially installed hook listener set', async () => { + const ctx = new Context() + await ctx.plugin({ + name: 'codex-invariant-bash', + apply(child: Context) { + child.provide('bash', { + resolve() {}, + async run() {}, + start() {}, + } as unknown as BashExecutor) + }, + }) + await expect(ctx.plugin({ + name: 'hooks-codex', + inject: ['bash'], + apply(child: Context) { + child.effect(() => () => {}, 'ctx.on("agent/session-start")') + }, + })).rejects.toThrow(/must install its complete listener set atomically/) + }) +}) diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts index c3e6b5e153..3327cec448 100644 --- a/packages/llm/llm-deepseek/src/invariant.ts +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-deepseek`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-llm-deepseek/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-deepseek`. @module @deepseek-ai/dsh-llm-deepseek/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-deepseek' @@ -17,8 +10,18 @@ export const name = 'llm-deepseek-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'llm-deepseek', + inject: [ + 'llm', + ], + effects: [ + 'llm.registerAdapter()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts index 4ada7a1a51..ba546fb902 100644 --- a/packages/llm/llm-pi-ai/src/invariant.ts +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-pi-ai`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-llm-pi-ai/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-pi-ai`. @module @deepseek-ai/dsh-llm-pi-ai/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-pi-ai' @@ -17,8 +10,18 @@ export const name = 'llm-pi-ai-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'llm-pi-ai', + inject: [ + 'llm', + ], + effects: [ + 'llm.registerAdapter()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index a8a56ce245..a4b8b50a3f 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-llm`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-llm/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm`. @module @deepseek-ai/dsh-llm/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm' @@ -17,8 +10,18 @@ export const name = 'llm-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LlmService', + effects: [ + 'ctx.provide("llm")', + ], + services: [ + 'llm', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index 00ceb567be..838a9409e8 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-token-meter`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-token-meter/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-token-meter`. @module @deepseek-ai/dsh-token-meter/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-token-meter' @@ -17,8 +10,19 @@ export const name = 'token-meter-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'TokenMeterService', + effects: [ + 'ctx.provide("tokenMeter")', + 'ctx.on("session/event")', + ], + services: [ + 'tokenMeter', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts index d9e75e9955..3cb9dd6005 100644 --- a/packages/mcp/mcp-client/src/invariant.ts +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-mcp-client`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-mcp-client/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-mcp-client`. @module @deepseek-ai/dsh-mcp-client/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-mcp-client' @@ -17,8 +10,19 @@ export const name = 'mcp-client-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'mcp-client', + inject: [ + 'tools', + ], + effects: [ + 'mcp-client.serverName', + 'mcp-client.connection', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts index 3582962f94..da6897f755 100644 --- a/packages/sandbox/sandbox-local/src/invariant.ts +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-sandbox-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-sandbox-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-sandbox-local`. @module @deepseek-ai/dsh-sandbox-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-local' @@ -17,8 +10,18 @@ export const name = 'sandbox-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LocalSandboxProvider', + effects: [ + 'ctx.provide("sandbox")', + ], + services: [ + 'sandbox', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts index b220f11717..cd1f767df0 100644 --- a/packages/sandbox/sandbox/src/invariant.ts +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-sandbox`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-sandbox/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-sandbox`. @module @deepseek-ai/dsh-sandbox/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox' @@ -17,8 +10,12 @@ export const name = 'sandbox-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'sandbox', value => serviceShapeViolation(value, { + methods: ['confine'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts index 87a697e438..66a95e6a38 100644 --- a/packages/sdk/create-sdk/src/invariant.ts +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/create-sdk`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/create-sdk/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/create-sdk. @module @deepseek-ai/create-sdk/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/create-sdk' @@ -17,8 +11,26 @@ export const name = 'create-sdk-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert the bin-only entrypoint and its core argument mapping. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { parseCreateArgs } = await import('./args.ts') + const packageEntry = await import('./index.ts') + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the create-sdk library entrypoint must remain empty because the package is bin-only') + const parsed = parseCreateArgs([ + 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', + ]) + assertInvariant(fail, + parsed.directory === 'workspace' + && parsed.provider === 'custom' + && parsed.baseURL === 'https://example.test' + && parsed.runInterface === 'embed' + && parsed.install === false, + 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') + return () => {} + }, 'create-sdk: validate bin and argument contracts') +} /** * Register this package's invariant companion. diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts index 63a6fc2055..e91fb012d5 100644 --- a/packages/sdk/helper/src/invariant.ts +++ b/packages/sdk/helper/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-helper`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-helper/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-helper. @module @deepseek-ai/dsh-helper/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-helper' @@ -17,8 +11,22 @@ export const name = 'helper-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert FeatureId's zero-cost representation and boundary validation. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { featureId } = await import('./ids.ts') + assertInvariant(fail, featureId('local-plugin') === 'local-plugin', + 'a valid feature id must preserve its runtime string value') + let rejected = false + try { + featureId('Invalid Feature') + } catch (error) { + rejected = error instanceof Error + } + assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') + return () => {} + }, 'dsh-helper: validate feature identities') +} /** * Register this package's invariant companion. diff --git a/packages/sdk/scripts/src/args.ts b/packages/sdk/scripts/src/args.ts index 1b91269592..a1f0a2eb0d 100644 --- a/packages/sdk/scripts/src/args.ts +++ b/packages/sdk/scripts/src/args.ts @@ -6,6 +6,7 @@ import { parseArgs as parseNodeArgs } from 'node:util' import { Command } from 'commander' +import { splitForwardedArgs } from './forwarding.ts' /** Commands implemented by the dsh-sdk launcher. */ type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' @@ -33,9 +34,7 @@ export function parseDshSdkArgs(argv: readonly string[]): DshSdkArgs { if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { return { forwarded: [], help: true } } - const separator = argv.indexOf('--') - const launcherArgv = separator === -1 ? argv : argv.slice(0, separator) - const passthrough = separator === -1 ? [] : argv.slice(separator + 1) + const { launcher: launcherArgv, forwarded: passthrough } = splitForwardedArgs(argv) let parsed: DshSdkArgs | undefined const program = new Command() .name('dsh-sdk') diff --git a/packages/sdk/scripts/src/forwarding.ts b/packages/sdk/scripts/src/forwarding.ts new file mode 100644 index 0000000000..56fa85f564 --- /dev/null +++ b/packages/sdk/scripts/src/forwarding.ts @@ -0,0 +1,21 @@ +/** Argument-delimiter handling shared by the SDK launcher and its invariant. */ + +/** Launcher-owned arguments and opaque arguments following `--`. */ +export interface ForwardedArgumentSplit { + /** Arguments parsed by the SDK launcher. */ + readonly launcher: readonly string[] + /** Arguments passed unchanged to the selected project command. */ + readonly forwarded: readonly string[] +} + +/** + * Split the first `--` delimiter without interpreting either side. + * @param argv - complete user argument vector. + * @returns launcher arguments and post-delimiter arguments. + */ +export function splitForwardedArgs(argv: readonly string[]): ForwardedArgumentSplit { + const separator = argv.indexOf('--') + return separator === -1 + ? { launcher: argv, forwarded: [] } + : { launcher: argv.slice(0, separator), forwarded: argv.slice(separator + 1) } +} diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts index fd0a0ef55b..316e7ebed1 100644 --- a/packages/sdk/scripts/src/invariant.ts +++ b/packages/sdk/scripts/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-scripts`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-scripts/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-scripts. @module @deepseek-ai/dsh-scripts/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' @@ -17,8 +11,24 @@ export const name = 'scripts-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert the launcher's opaque post-separator forwarding boundary. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { splitForwardedArgs } = await import('./forwarding.ts') + const plain = splitForwardedArgs(['dev', 'src/index.ts']) + const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) + assertInvariant(fail, + plain.launcher.length === 2 + && plain.forwarded.length === 0 + && separated.launcher.length === 2 + && separated.launcher[1] === 'src/index.ts' + && separated.forwarded.length === 2 + && separated.forwarded[0] === '--inspect' + && separated.forwarded[1] === '9229', + 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') + return () => {} + }, 'dsh-sdk: validate command argument contracts') +} /** * Register this package's invariant companion. diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 8d110799ea..6f1af04a4e 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -132,6 +132,7 @@ describe('Commander launcher arguments', () => { expect(parseDshSdkArgs(['start'])).toEqual({ command: 'start', forwarded: [], help: false }) expect(parseDshSdkArgs(['dev', 'index.ts'])).toMatchObject({ command: 'dev', target: 'index.ts' }) expect(parseDshSdkArgs(['-h'])).toMatchObject({ help: true }) + expect(parseDshSdkArgs(['--help'])).toMatchObject({ help: true }) expect(() => parseDshSdkArgs(['unknown'])).toThrow() expect(() => parseDshSdkArgs(['config', 'extra'])).toThrow() expect(() => parseDshSdkArgs(['config', '--', 'extra'])).toThrow('does not accept forwarded') diff --git a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts index 12c65db1c4..a3f3854cff 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence-jsonl`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence-jsonl`. * @module @deepseek-ai/dsh-session-persistence-jsonl/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-jsonl' @@ -17,8 +13,21 @@ export const name = 'session-persistence-jsonl-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SessionPersistenceJsonl', + inject: [ + 'sessions', + ], + effects: [ + 'ctx.provide("sessionPersistence")', + ], + services: [ + 'sessionPersistence', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +36,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts index a9b04cf5f5..8eb41c061b 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence-sqlite`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence-sqlite`. * @module @deepseek-ai/dsh-session-persistence-sqlite/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite' @@ -17,8 +13,21 @@ export const name = 'session-persistence-sqlite-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SessionPersistenceSqlite', + inject: [ + 'sessions', + ], + effects: [ + 'ctx.provide("sessionPersistence")', + ], + services: [ + 'sessionPersistence', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +36,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence/src/invariant.ts b/packages/session-persistence/session-persistence/src/invariant.ts index cc7cc8fa2c..c62805d300 100644 --- a/packages/session-persistence/session-persistence/src/invariant.ts +++ b/packages/session-persistence/session-persistence/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-session-persistence`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence`. * @module @deepseek-ai/dsh-session-persistence/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence' @@ -17,8 +13,12 @@ export const name = 'session-persistence-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'sessionPersistence', value => serviceShapeViolation(value, { + methods: ['locate', 'create', 'append', 'load', 'list'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +27,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts index 91bcee721e..0f7a93acde 100644 --- a/packages/session-query/session-query/src/invariant.ts +++ b/packages/session-query/session-query/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-session-query`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-session-query/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-session-query`. @module @deepseek-ai/dsh-session-query/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-query' @@ -17,8 +10,21 @@ export const name = 'session-query-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SessionQueryService', + inject: [ + 'sessions', + ], + effects: [ + 'ctx.provide("sessionQuery")', + ], + services: [ + 'sessionQuery', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/skill/skill-local/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts index 475d02bb8f..7afa8c5023 100644 --- a/packages/skill/skill-local/src/invariant.ts +++ b/packages/skill/skill-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-skill-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-skill-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-skill-local`. @module @deepseek-ai/dsh-skill-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill-local' @@ -17,8 +10,18 @@ export const name = 'skill-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'skill-local', + inject: [ + 'skills', + ], + effects: [ + 'skills.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts index c1d4091bb2..6a673db92a 100644 --- a/packages/skill/skill/src/invariant.ts +++ b/packages/skill/skill/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-skill`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-skill/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-skill`. @module @deepseek-ai/dsh-skill/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill' @@ -17,8 +10,18 @@ export const name = 'skill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SkillService', + effects: [ + 'ctx.provide("skills")', + ], + services: [ + 'skills', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts index abf4ba3961..8e6d6bbc3c 100644 --- a/packages/skill/tool-skill/src/invariant.ts +++ b/packages/skill/tool-skill/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-skill`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-skill/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-skill`. @module @deepseek-ai/dsh-tool-skill/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-skill' @@ -17,8 +10,20 @@ export const name = 'tool-skill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-skill', + inject: [ + 'tools', + 'skills', + ], + effects: [ + 'tools.register()', + 'ctx.on("agent/session-prefix")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts index d638ffa2a9..b769007d7d 100644 --- a/packages/spill/spill-local/src/invariant.ts +++ b/packages/spill/spill-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-spill-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-spill-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill-local`. @module @deepseek-ai/dsh-spill-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-local' @@ -17,8 +10,18 @@ export const name = 'spill-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'LocalSpillStore', + effects: [ + 'ctx.provide("spillStore")', + ], + services: [ + 'spillStore', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts index d4aa544ecd..4af3a81f70 100644 --- a/packages/spill/spill-policy/src/invariant.ts +++ b/packages/spill/spill-policy/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-spill-policy`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-spill-policy/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill-policy`. @module @deepseek-ai/dsh-spill-policy/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-policy' @@ -17,8 +10,22 @@ export const name = 'spill-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'spill-policy', + inject: [ + 'tools', + ], + validate: (fiber, effectLabels) => { + const installed = effectLabels.has('ctx.on("tools/post-execute")') + const enabled = (fiber.config as { maxInlineBytes?: number }).maxInlineBytes !== undefined + return installed === enabled + ? undefined + : 'the post-execute spill policy listener must exist exactly when maxInlineBytes is configured' + }, + }) +} /** * Register this package's invariant companion. @@ -27,4 +34,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 2449f26a8c..f0cbbbd2a0 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -109,6 +109,17 @@ describe('config validation', () => { it('rejects a fractional maxInlineBytes at load', async () => { await expect(setup({ maxInlineBytes: 1.5 })).rejects.toThrow(/non-negative integer/) }) + + it('rejects a configured policy that omits its post-execute listener', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await expect(ctx.plugin({ + name: 'spill-policy', + inject: ['tools'], + apply(_child: Context, _config: { maxInlineBytes?: number }) {}, + }, { maxInlineBytes: 10 })).rejects.toThrow(/listener must exist exactly when maxInlineBytes is configured/) + }) }) describe('oversized plain-text replacement', () => { diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts index 714e43f3a6..ba971b8bc2 100644 --- a/packages/spill/spill/src/invariant.ts +++ b/packages/spill/spill/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-spill`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-spill/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill`. @module @deepseek-ai/dsh-spill/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill' @@ -17,8 +10,12 @@ export const name = 'spill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'spillStore', value => serviceShapeViolation(value, { + methods: ['saveText'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts index a5828fd4c1..0c17843ff7 100644 --- a/packages/subagent/subagent-acp/src/invariant.ts +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-acp`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-acp/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-acp`. @module @deepseek-ai/dsh-subagent-acp/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-acp' @@ -17,8 +10,18 @@ export const name = 'subagent-acp-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'subagent-acp', + inject: [ + 'subagents', + ], + effects: [ + 'subagents.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts index c6903fd82d..2fc8a0f696 100644 --- a/packages/subagent/subagent-fork/src/invariant.ts +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-fork`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-fork/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-fork`. @module @deepseek-ai/dsh-subagent-fork/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork' @@ -17,8 +10,18 @@ export const name = 'subagent-fork-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'subagent-fork', + inject: [ + 'subagents', + ], + effects: [ + 'subagents.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index 0eac204104..a007200d73 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-inprocess`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-inprocess/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-subagent-inprocess. @module @deepseek-ai/dsh-subagent-inprocess/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' @@ -17,8 +11,17 @@ export const name = 'subagent-inprocess-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert that structured-output guidance names the tool it actually installs. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') + assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), + 'the structured-output tool must retain a stable lowercase protocol name') + assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), + 'the structured-output instruction must name the exact installed tool') + return () => {} + }, 'subagent-inprocess: validate structured-output protocol') +} /** * Register this package's invariant companion. diff --git a/packages/subagent/subagent-inprocess/src/structured-protocol.ts b/packages/subagent/subagent-inprocess/src/structured-protocol.ts new file mode 100644 index 0000000000..8ae87e73b0 --- /dev/null +++ b/packages/subagent/subagent-inprocess/src/structured-protocol.ts @@ -0,0 +1,10 @@ +/** Model-facing constants shared by structured child execution and its invariant. */ + +/** The model-facing tool name a structured child must call to finish. */ +export const STRUCTURED_OUTPUT_TOOL = 'structured_output' + +/** The terminal structured-result instruction appended to a child request. */ +export const STRUCTURED_OUTPUT_INSTRUCTION + = 'When you have your final answer, you MUST report it by calling the ' + + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` + + 'Do not finish with a plain text answer: only the tool call counts as your result.' diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 09aa2d24b7..cecb7d173e 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -15,19 +15,9 @@ import type { ContinuationStop } from '@deepseek-ai/dsh-agent' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } from './structured-protocol.ts' -/** The model-facing tool name a structured child must call to finish. */ -export const STRUCTURED_OUTPUT_TOOL = 'structured_output' - -/** - * The instruction registered as the child's trailing (order-190, the end of - * the tool-guidance band) scoped prompt section: the demand travels with the - * tool, as ordinary prompt state of exactly one agent. - */ -export const STRUCTURED_OUTPUT_INSTRUCTION - = 'When you have your final answer, you MUST report it by calling the ' - + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` - + 'Do not finish with a plain text answer: only the tool call counts as your result.' +export { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } from './structured-protocol.ts' /** One structured run's live handle: read the captured value once the child settles. */ export interface StructuredAttachment { diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts index d179a2b72a..28201593e4 100644 --- a/packages/subagent/subagent-spawn/src/invariant.ts +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-spawn`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-spawn/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-spawn`. @module @deepseek-ai/dsh-subagent-spawn/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-spawn' @@ -17,8 +10,18 @@ export const name = 'subagent-spawn-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'subagent-spawn', + inject: [ + 'subagents', + ], + effects: [ + 'subagents.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index 22dac32814..ed29ecc855 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -1,24 +1,32 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent-subprocess`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent-subprocess/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-subagent-subprocess. @module @deepseek-ai/dsh-subagent-subprocess/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** Cordis companion plugin name. */ export const name = 'subagent-subprocess-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert ambient credential scrubbing and explicit credential precedence. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { buildChildEnv } = await import('./index.ts') + const scrubbed = buildChildEnv({}) + const ambientSensitiveNames = Object.keys(process.env).filter(key => SENSITIVE_ENV_PATTERN.test(key)) + assertInvariant(fail, ambientSensitiveNames.every(key => !Object.hasOwn(scrubbed, key)), + 'subprocess environments must omit every credential-shaped ambient variable') + + const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) + assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', + 'explicit child credentials must be applied after ambient scrubbing') + return () => {} + }, 'subagent-subprocess: validate child environment isolation') +} /** * Register this package's invariant companion. diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index 3a79592ee1..3217a3dd9a 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-subagent`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-subagent/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent`. @module @deepseek-ai/dsh-subagent/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent' @@ -17,8 +10,18 @@ export const name = 'subagent-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'SubagentService', + effects: [ + 'ctx.provide("subagents")', + ], + services: [ + 'subagents', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 08881e4b2b..b1bfa209a1 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-subagent`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-subagent/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-subagent`. @module @deepseek-ai/dsh-tool-subagent/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' @@ -17,8 +10,20 @@ export const name = 'tool-subagent-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-subagent', + inject: [ + 'tools', + 'subagents', + ], + effects: [ + 'ctx.on("subagent/provider-added")', + 'ctx.on("subagent/provider-removed")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index 3579b96cf5..fc568d7b37 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-acp-snapshot`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-acp-snapshot/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-acp-snapshot. @module @deepseek-ai/dsh-acp-snapshot/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-snapshot' @@ -17,8 +11,27 @@ export const name = 'acp-snapshot-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert stable JSON-RPC correlation and volatile-value tokenization. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { normalizeStdout } = await import('./normalize.ts') + const sessionId = '12345678-1234-1234-1234-123456789abc' + const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } + const raw = [ + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), + ].join('\n') + const normalized = normalizeStdout(raw, volatile) + assertInvariant(fail, + normalized.includes('"id":1') + && normalized.includes('"cwd":"{{cwd}}"') + && normalized.includes('"sessionId":"{{sessionId}}"'), + 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') + assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, + 'ACP stdout normalization must be idempotent') + return () => {} + }, 'acp-snapshot: validate stable transcript normalization') +} /** * Register this package's invariant companion. diff --git a/packages/support/agent-loop-testkit/src/index.ts b/packages/support/agent-loop-testkit/src/index.ts index c7b0cb7304..ca7148ee23 100644 --- a/packages/support/agent-loop-testkit/src/index.ts +++ b/packages/support/agent-loop-testkit/src/index.ts @@ -6,12 +6,7 @@ */ import type { Context } from 'cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' import type { Config as ToolRegistryConfig } from '@deepseek-ai/dsh-tools' /** Configuration forwarded to the prerequisite service plugins. */ @@ -38,6 +33,19 @@ export async function mountAgentLoopTestDependencies( ctx: Context, options: AgentLoopTestDependenciesOptions = {}, ): Promise { + const [ + { default: LlmService }, + { default: SessionStore }, + { default: SystemPrompt }, + { default: ToolRegistry }, + { default: AgentRegistry }, + ] = await Promise.all([ + import('@deepseek-ai/dsh-llm'), + import('@deepseek-ai/dsh-session'), + import('@deepseek-ai/dsh-system-prompt'), + import('@deepseek-ai/dsh-tools'), + import('@deepseek-ai/dsh-agent'), + ]) await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index fc2554aa77..fd72038d3f 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-agent-loop-testkit`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-agent-loop-testkit/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-agent-loop-testkit. @module @deepseek-ai/dsh-agent-loop-testkit/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop-testkit' @@ -17,8 +11,18 @@ export const name = 'agent-loop-testkit-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert the awaitable helper shape and optional-options call boundary. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { mountAgentLoopTestDependencies } = await import('./index.ts') + assertInvariant(fail, + mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', + 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') + assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, + 'the prerequisite mount helper must keep its options argument optional') + return () => {} + }, 'agent-loop-testkit: validate prerequisite mount boundary') +} /** * Register this package's invariant companion. diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index c49771ded9..74e8b34310 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -24,9 +24,17 @@ The service owns every registration fiber, while the returned disposer also belo ## Package companions -An ownership-only generated baseline installs no listeners but still reserves its package name through the real service boundary. A package replaces that marked file when it gains a relational check, retaining the same registration. `pnpm run verify-package-invariants` checks every package's source registration, export, published files, dependencies, TypeScript reference, and bundle entry. +Every companion installs at least one executable, package-specific contract and reports failure through its bound reporter. There is no generated or ownership-only baseline. `pnpm run verify-package-invariants` rejects generated markers, empty installers, installers that ignore the reporter, duplicate name-based plugin observers, incorrect registration names, and incomplete export, publication, dependency, TypeScript-reference, or bundle wiring. -Four companions currently install stateful checks: +Packages select the narrowest runtime form that protects their public contract: + +| Package shape | Companion check | +|---|---| +| Cordis plugin | `observePluginInvariant` validates the plugin's own declared name, required injections, owned effect group, provided services, and optional package-specific relation for existing, late, and HMR-activated fibers. | +| Cordis service seam | `observeServiceInvariant` plus `serviceShapeViolation` validates current and future structural implementations, including conforming third-party backends and test doubles. | +| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape in a child effect. | + +Four companions additionally install stateful event and request checks: | Companion | Registration | Checks | |---|---|---| @@ -35,7 +43,7 @@ Four companions currently install stateful checks: | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | loop-built model-request reconstruction from the session log | -The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency. +The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency. Name-based plugin observers match only a fiber's own declared runtime name, not anonymous child fibers that inherit a parent display name. They avoid importing the product entrypoint before it is loaded; pure-library checks likewise defer owner imports into the installer child so Vitest mocks and deployment loaders establish their module boundary first. ## Composition @@ -54,7 +62,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and the four stateful companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so baseline ownership and stateful checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. +The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so all package checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. ## Model Experience @@ -62,6 +70,7 @@ None, as the service and companions observe runtime events and requests but neve ## Known Limitations and Deferred Work -- Stateful checks cover only the four listed package contracts; other companions reserve ownership but add no listeners until their packages gain relational assertions. +- A name-based plugin observer assumes Cordis plugin names are unique within one root; a package can provide the exact callback when importing it does not preload an unrelated runtime. +- Pure-library contracts are sampled when their companion child activates rather than observed continuously; mutable package behavior belongs on an event, service, or plugin-fiber observer. - Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract. - Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 4bbf218e97..374012fdbd 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,14 +1,13 @@ /** * Configurable registry for package-owned runtime invariant contributions. - * Every workspace package registers its name from a `./invariant` companion; - * ordinary package entrypoints stay independent of diagnostics, and packages - * without relational checks use an ownership-only installer. + * Every workspace package registers checks from a `./invariant` companion; + * ordinary package entrypoints stay independent of diagnostics. * * @module @deepseek-ai/dsh-invariants */ -import { Context, Service } from 'cordis' -import type { Inject } from 'cordis' +import { Context, FiberState, Service } from 'cordis' +import type { Fiber, Inject, Plugin } from 'cordis' import z from 'schemastery' import type Schema from 'schemastery' @@ -42,6 +41,173 @@ export interface InvariantInstaller { readonly inject?: Inject } +/** Runtime facts one package expects from its Cordis plugin fiber. */ +export interface PluginInvariantContract { + /** Exact plugin value when checking it does not preload an unrelated runtime; otherwise matching uses `name`. */ + readonly plugin?: Plugin + /** Exact Cordis display name for the plugin fiber. */ + readonly name: string + /** Required service injections that must be present when the fiber activates. */ + readonly inject?: readonly string[] + /** Required owned effect labels; an inner array means at least one alternative must exist. */ + readonly effects?: readonly (string | readonly string[])[] + /** Services the active fiber must provide. */ + readonly services?: readonly string[] + /** Optional package-owned validation after the structural checks pass. */ + readonly validate?: (fiber: Fiber, effectLabels: ReadonlySet) => string | undefined +} + +/** Collect all live effect labels below a plugin fiber. */ +function collectEffectLabels(fiber: Fiber): ReadonlySet { + const labels = new Set() + const visit = (effects: ReturnType): void => { + for (const effect of effects) { + labels.add(effect.label) + visit(effect.children) + } + } + visit(fiber.getEffects()) + return labels +} + +/** + * Observe one package plugin and fail whenever an active fiber violates its + * declared name, dependency, effect, service, or package-specific contract. + * Existing fibers are checked immediately; later starts and HMR activations + * are checked through Cordis lifecycle events. + * @param ctx - invariant child context that owns the observers. + * @param fail - reporter bound to the package that owns the plugin. + * @param contract - expected runtime facts for the package plugin. + * @returns nothing after lifecycle observers are installed. + */ +export function observePluginInvariant( + ctx: Context, + fail: InvariantFailure, + contract: PluginInvariantContract, +): void { + const callback = contract.plugin === undefined ? undefined : ctx.registry.resolve(contract.plugin) + if (contract.plugin !== undefined && callback === undefined) { + fail('invariant contract does not identify a Cordis plugin') + } + + const inspect = (fiber: Fiber): void => { + const matches = callback === undefined + ? fiber.runtime?.name === contract.name + : fiber.runtime?.callback === callback + if (!matches || fiber.state !== FiberState.ACTIVE) return + if (callback !== undefined && fiber.name !== contract.name) { + fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) + } + const injections = new Set(Object.keys(fiber.inject)) + for (const service of contract.inject ?? []) { + if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) + } + + const effectLabels = collectEffectLabels(fiber) + for (const requirement of contract.effects ?? []) { + const alternatives = typeof requirement === 'string' ? [requirement] : requirement + if (!alternatives.some(label => effectLabels.has(label))) { + fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) + } + } + for (const service of contract.services ?? []) { + const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { + const implementation = fiber.ctx.reflect.store[key as symbol] + return implementation?.fiber === fiber && implementation.name === service + }) + if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) + } + const message = contract.validate?.(fiber, effectLabels) + if (message !== undefined) fail(message) + } + + if (contract.plugin === undefined) { + for (const runtime of ctx.registry.values()) { + for (const fiber of runtime.fibers) inspect(fiber) + } + } else { + for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) inspect(fiber) + } + ctx.on('internal/plugin', inspect, { global: true }) + ctx.on('internal/status', inspect, { global: true }) +} + +/** + * Validate every current and future implementation bound to one Cordis service. + * @param ctx - invariant child context that owns the service observer. + * @param fail - reporter bound to the package that owns the service seam. + * @param serviceName - Cordis service name to observe. + * @param validate - returns the violated contract, or `undefined` for a valid implementation. + * @returns nothing after the current binding is checked and the observer is installed. + */ +export function observeServiceInvariant( + ctx: Context, + fail: InvariantFailure, + serviceName: string, + validate: (value: unknown) => string | undefined, +): void { + const inspect = (value: unknown): void => { + if (value === undefined) return + const message = validate(value) + if (message !== undefined) fail(message) + } + const current: unknown = ctx.get(serviceName) + inspect(current) + ctx.on('internal/service', (name, value: unknown) => { + if (name === serviceName) inspect(value) + }, { global: true }) +} + +/** Structural runtime surface required from a Cordis service implementation. */ +export interface ServiceShapeInvariant { + /** Members that must be callable. */ + readonly methods: readonly string[] + /** Members that must be non-empty strings. */ + readonly stringProperties?: readonly string[] +} + +/** + * Describe the first missing member in a structural service implementation. + * This deliberately accepts test doubles and third-party implementations that + * satisfy the seam without inheriting the first-party abstract service class. + * @param value - candidate service implementation. + * @param shape - callable and string members owned by the service package. + * @returns the violated shape, or `undefined` when the candidate conforms. + */ +export function serviceShapeViolation( + value: unknown, + shape: ServiceShapeInvariant, +): string | undefined { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return 'service implementation must be an object' + } + const record = value as Record + for (const method of shape.methods) { + if (typeof record[method] !== 'function') return `service implementation must expose method ${JSON.stringify(method)}` + } + for (const property of shape.stringProperties ?? []) { + if (typeof record[property] !== 'string' || record[property].length === 0) { + return `service implementation must expose non-empty string ${JSON.stringify(property)}` + } + } + return undefined +} + +/** + * Report a failed package-owned synchronous invariant. + * @param fail - reporter bound to the package that owns the assertion. + * @param condition - condition that must hold. + * @param message - violated contract when `condition` is false. + * @returns nothing when the condition holds. + */ +export function assertInvariant( + fail: InvariantFailure, + condition: unknown, + message: string, +): void { + if (!condition) fail(message) +} + /** Internal effect shape used to join child startup before a companion loads. */ interface PendingInvariantRegistration extends PromiseLike<() => void> { (): void | Promise diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts index b6b4d8ae48..bd9efcb74c 100644 --- a/packages/support/invariants/src/invariant.ts +++ b/packages/support/invariants/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-invariants`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-invariants/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-invariants`. @module @deepseek-ai/dsh-invariants/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from './index.ts' +import InvariantService, { observePluginInvariant, type InvariantInstaller } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' @@ -17,8 +10,19 @@ export const name = 'invariants-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + plugin: InvariantService, + name: 'InvariantService', + effects: [ + 'ctx.provide("invariants")', + ], + services: [ + 'invariants', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index afeae22fb7..44f8d61139 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -1,10 +1,20 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' -import InvariantService, { InvariantError, type Config } from '@deepseek-ai/dsh-invariants' +import InvariantService, { + InvariantError, + assertInvariant, + observePluginInvariant, + observeServiceInvariant, + serviceShapeViolation, + type Config, + type InvariantInstaller, + type PluginInvariantContract, +} from '@deepseek-ai/dsh-invariants' declare module 'cordis' { interface Context { invariantProbe: InvariantProbeService + watchedInvariantProbe: WatchedInvariantProbeService } interface Events { @@ -18,6 +28,12 @@ class InvariantProbeService extends Service { } } +class WatchedInvariantProbeService extends Service { + constructor(ctx: Context) { + super(ctx, 'watchedInvariantProbe') + } +} + interface RuntimeRegistration extends PromiseLike<() => void> { (): void | Promise } @@ -264,3 +280,207 @@ describe('InvariantService lifecycle', () => { expect(() => service.register('@deepseek-ai/dsh-session', () => {})).toThrow(/inactive/i) }) }) + +describe('package-owned invariant helpers', () => { + async function registerInstaller( + ctx: Context, + packageName: string, + installer: InvariantInstaller, + ): Promise<() => void> { + const registration = runtimeRegistration(ctx.invariants.register(packageName, installer)) + const dispose = await Promise.resolve(registration) + return dispose + } + + function effectPlugin(options: { + name?: string + inject?: string[] + effect?: string + service?: string + } = {}) { + return { + name: options.name ?? 'effect-probe', + inject: options.inject ?? [], + apply(ctx: Context) { + if (options.service !== undefined) ctx.provide(options.service, {}) + if (options.effect !== undefined) { + ctx.effect(() => { + ctx.effect(() => () => {}, `${options.effect}.child`) + return () => {} + }, options.effect) + } + }, + } + } + + async function expectPluginViolation( + contract: PluginInvariantContract, + plugin: ReturnType, + message: RegExp, + ): Promise { + const { ctx } = await setup() + await registerInstaller(ctx, `@deepseek-ai/${contract.name}`, (child, fail) => { + observePluginInvariant(child, fail, contract) + }) + await expect(Promise.resolve(ctx.plugin(plugin))).rejects.toThrow(message) + } + + it('checks existing and later plugin fibers, including nested effects and alternatives', async () => { + const { ctx } = await setup() + await ctx.plugin(InvariantProbeService) + const plugin = effectPlugin({ + inject: ['invariantProbe'], + effect: 'probe.effect', + service: 'pluginProbe', + }) + await ctx.plugin(plugin) + const validated = vi.fn(() => undefined) + await registerInstaller(ctx, '@deepseek-ai/dsh-existing-probe', (child, fail) => { + observePluginInvariant(child, fail, { + plugin, + name: 'effect-probe', + inject: ['invariantProbe'], + effects: [['missing.effect', 'probe.effect.child']], + services: ['pluginProbe'], + validate: validated, + }) + }) + expect(validated).toHaveBeenCalledOnce() + + const later = effectPlugin({ name: 'later-probe', effect: 'later.effect' }) + await registerInstaller(ctx, '@deepseek-ai/dsh-later-probe', (child, fail) => { + observePluginInvariant(child, fail, { + plugin: later, + name: 'later-probe', + effects: ['later.effect'], + }) + }) + await ctx.plugin(later) + }) + + it('matches package plugins by Cordis name without importing their callback', async () => { + const { ctx } = await setup() + const plugin = { + name: 'name-only-probe', + apply(pluginCtx: Context) { + pluginCtx.effect(() => () => {}, 'name-only.effect') + pluginCtx.inject([], () => {}) + }, + } + await registerInstaller(ctx, '@deepseek-ai/dsh-name-only-probe', (child, fail) => { + observePluginInvariant(child, fail, { + name: 'name-only-probe', + effects: ['name-only.effect'], + }) + }) + await ctx.plugin(plugin) + }) + + it('rejects a contract that does not identify a plugin', async () => { + const { ctx } = await setup() + const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-plugin', (child, fail) => { + observePluginInvariant(child, fail, { + plugin: {} as never, + name: 'invalid-plugin', + }) + })) + await expect(Promise.resolve(registration)).rejects.toThrow(/does not identify a Cordis plugin/) + }) + + it('rejects wrong plugin names, missing injections, effects, services, and custom checks', async () => { + const wrongName = effectPlugin({ name: 'actual-name', effect: 'probe.effect' }) + await expectPluginViolation({ + plugin: wrongName, + name: 'expected-name', + }, wrongName, /plugin name must be "expected-name"/) + + const missingInjection = effectPlugin({ effect: 'probe.effect' }) + await expectPluginViolation({ + plugin: missingInjection, + name: 'effect-probe', + inject: ['missingService'], + }, missingInjection, /must inject "missingService"/) + + const missingEffect = effectPlugin() + await expectPluginViolation({ + plugin: missingEffect, + name: 'effect-probe', + effects: [['first.effect', 'second.effect']], + }, missingEffect, /must own effect "first.effect" or "second.effect"/) + + const missingService = effectPlugin({ effect: 'probe.effect' }) + await expectPluginViolation({ + plugin: missingService, + name: 'effect-probe', + services: ['missingService'], + }, missingService, /must provide service "missingService"/) + + const invalidCustom = effectPlugin({ effect: 'probe.effect' }) + await expectPluginViolation({ + plugin: invalidCustom, + name: 'effect-probe', + validate: () => 'custom plugin contract failed', + }, invalidCustom, /custom plugin contract failed/) + }) + + it('checks existing and future service implementations while ignoring unrelated changes', async () => { + const existing = await setup() + await existing.ctx.plugin(WatchedInvariantProbeService) + await registerInstaller(existing.ctx, '@deepseek-ai/dsh-existing-service', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', value => ( + value instanceof WatchedInvariantProbeService ? undefined : 'wrong watched service' + )) + }) + + const future = await setup() + await registerInstaller(future.ctx, '@deepseek-ai/dsh-future-service', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', value => ( + value instanceof WatchedInvariantProbeService ? undefined : 'wrong watched service' + )) + }) + await future.ctx.plugin(InvariantProbeService) + await future.ctx.plugin(WatchedInvariantProbeService) + + const invalid = await setup() + await registerInstaller(invalid.ctx, '@deepseek-ai/dsh-invalid-service', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', () => 'wrong watched service') + }) + await expect(Promise.resolve(invalid.ctx.plugin(WatchedInvariantProbeService))) + .rejects.toThrow(/wrong watched service/) + }) + + it('reports synchronous package assertions through the bound failure reporter', async () => { + const { ctx } = await setup() + const valid = await registerInstaller(ctx, '@deepseek-ai/dsh-valid-assertion', (_child, fail) => { + assertInvariant(fail, true, 'must stay true') + }) + valid() + + const invalid = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-assertion', (_child, fail) => { + assertInvariant(fail, false, 'must stay true') + })) + await expect(Promise.resolve(invalid)).rejects.toThrow(/must stay true/) + }) + + it('accepts structural service implementations and test doubles', () => { + expect(serviceShapeViolation({ kind: 'probe', run() {} }, { + methods: ['run'], + stringProperties: ['kind'], + })).toBeUndefined() + expect(serviceShapeViolation(Object.assign(() => {}, { run() {} }), { + methods: ['run'], + })).toBeUndefined() + }) + + it.each([ + { value: null, message: 'service implementation must be an object' }, + { value: 42, message: 'service implementation must be an object' }, + { value: {}, message: 'service implementation must expose method "run"' }, + { value: { run() {}, kind: '' }, message: 'service implementation must expose non-empty string "kind"' }, + ])('rejects invalid structural service implementations: $message', ({ value, message }) => { + expect(serviceShapeViolation(value, { + methods: ['run'], + stringProperties: ['kind'], + })).toBe(message) + }) +}) diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts index 50295cbedf..9c82c33686 100644 --- a/packages/support/llm-replay/src/invariant.ts +++ b/packages/support/llm-replay/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-llm-replay`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-llm-replay/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-replay`. @module @deepseek-ai/dsh-llm-replay/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-replay' @@ -17,8 +10,21 @@ export const name = 'llm-replay-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'llm-replay', + inject: [ + 'llm', + ], + effects: [ + [ + 'llm.registerAdapter()', + 'ctx.on("llm/stream")', + ], + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 9265a5f8b4..826d33dd30 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-loader-smoke`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-loader-smoke/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-loader-smoke. @module @deepseek-ai/dsh-loader-smoke/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-loader-smoke' @@ -17,8 +11,25 @@ export const name = 'loader-smoke-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert default source mode and plain-Node built-artifact launch resolution. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') + assertInvariant(fail, resolveExampleMode('') === 'src', + 'an empty example-mode selection must preserve source-mode development') + const launch = resolveExampleLaunch({ + srcBin: '/workspace/probe/src/bin.ts', + mode: 'lib', + }) + assertInvariant(fail, + launch.command === process.execPath + && launch.args.length === 1 + && launch.args[0] === '/workspace/probe/lib/bin.js' + && launch.env.TSX_TSCONFIG_PATH === undefined, + 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') + return () => {} + }, 'loader-smoke: validate source and built launch resolution') +} /** * Register this package's invariant companion. diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts index 468fe664b9..ce3bf24175 100644 --- a/packages/tasks/tasks/src/invariant.ts +++ b/packages/tasks/tasks/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tasks`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tasks/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tasks`. @module @deepseek-ai/dsh-tasks/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tasks' @@ -17,8 +10,19 @@ export const name = 'tasks-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'TaskService', + effects: [ + 'ctx.provide("tasks")', + 'tasks teardown', + ], + services: [ + 'tasks', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts index fded38c895..e4af0931d2 100644 --- a/packages/tasks/tool-tasks/src/invariant.ts +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-tasks`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-tasks/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-tasks`. @module @deepseek-ai/dsh-tool-tasks/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-tasks' @@ -17,8 +10,20 @@ export const name = 'tool-tasks-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-tasks', + inject: [ + 'tools', + 'tasks', + 'systemPrompt', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/timeout/timeout-policy/src/invariant.ts b/packages/timeout/timeout-policy/src/invariant.ts index 9e7b5b7d4b..b0e564dbbd 100644 --- a/packages/timeout/timeout-policy/src/invariant.ts +++ b/packages/timeout/timeout-policy/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-timeout-policy`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-timeout-policy/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-timeout-policy`. @module @deepseek-ai/dsh-timeout-policy/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout-policy' @@ -17,8 +10,18 @@ export const name = 'timeout-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'timeout-policy', + inject: [ + 'tools', + ], + effects: [ + 'ctx.on("tools/execute")', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index a5980342f3..9ad57156f4 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-todo`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-todo/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-todo`. @module @deepseek-ai/dsh-tool-todo/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-todo' @@ -17,8 +10,18 @@ export const name = 'tool-todo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-todo', + inject: [ + 'tools', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/acp/src/invariant.ts b/packages/ui/acp/src/invariant.ts index 2c081fc48c..1b98d6630c 100644 --- a/packages/ui/acp/src/invariant.ts +++ b/packages/ui/acp/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-acp`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-acp/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-acp`. @module @deepseek-ai/dsh-acp/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp' @@ -17,8 +10,25 @@ export const name = 'acp-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'acp', + inject: [ + 'agents', + 'sessionPersistence', + 'tools', + 'userInteraction', + 'llm', + 'systemPrompt', + ], + effects: [ + 'userInteraction.registerProvider()', + 'ctx.on("session/event")', + 'acp.connection', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +37,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/app-boot/src/config-path.ts b/packages/ui/app-boot/src/config-path.ts new file mode 100644 index 0000000000..bdfb933feb --- /dev/null +++ b/packages/ui/app-boot/src/config-path.ts @@ -0,0 +1,23 @@ +/** Snapshot-aware application configuration path selection. @module @deepseek-ai/dsh-app-boot/config-path */ + +import { basename, dirname, resolve } from 'node:path' + +/** + * Resolve the config to boot. Replay swaps a `cordis.yml` basename for + * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. + * @param configPath - requested config path, absolute or relative to `cwd`. + * @param snapshotMode - bin `$DSH_SNAPSHOT`; only `replay` swaps the basename. + * @param cwd - base for a relative `configPath`. + * @returns the absolute path of the config to boot. + */ +export function resolveConfigPath( + configPath: string, + snapshotMode: string | undefined, + cwd: string = process.cwd(), +): string { + const absolute = resolve(cwd, configPath) + if (snapshotMode !== 'replay') return absolute + const dir = dirname(absolute) + const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') + return resolve(dir, replayName) +} diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 91ab0d3a2f..e929449287 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -6,29 +6,12 @@ */ import { pathToFileURL } from 'node:url' -import { basename, dirname, resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' -/** - * Resolve the config to boot. Replay swaps a `cordis.yml` basename for - * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. - * @param configPath - the requested config path (absolute, or relative to `cwd`). - * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the - * basename. - * @param cwd - the base a relative `configPath` resolves against. - * @returns the absolute path of the config to boot. - */ -export function resolveConfigPath( - configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(), -): string { - const absolute = resolve(cwd, configPath) - if (snapshotMode !== 'replay') return absolute - const dir = dirname(absolute) - const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') - return resolve(dir, replayName) -} +export { resolveConfigPath } from './config-path.ts' /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts index 498d967799..30e2cfea69 100644 --- a/packages/ui/app-boot/src/invariant.ts +++ b/packages/ui/app-boot/src/invariant.ts @@ -1,14 +1,9 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-app-boot`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-app-boot/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-app-boot. @module @deepseek-ai/dsh-app-boot/invariant */ /* jscpd:ignore-start */ +import { resolve } from 'node:path' import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot' @@ -17,8 +12,20 @@ export const name = 'app-boot-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert ordinary and replay config-path selection. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { resolveConfigPath } = await import('./config-path.ts') + const cwd = '/tmp/dsh-app-boot-invariant' + const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) + const replay = resolveConfigPath('cordis.yml', 'replay', cwd) + assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), + 'ordinary app boot must retain the requested config basename') + assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), + 'snapshot replay must select cordis.snapshot.yml in the requested config directory') + return () => {} + }, 'app-boot: validate ordinary and replay config selection') +} /** * Register this package's invariant companion. diff --git a/packages/ui/jsonrpc/src/invariant.ts b/packages/ui/jsonrpc/src/invariant.ts index 552c312481..aad2ac9f41 100644 --- a/packages/ui/jsonrpc/src/invariant.ts +++ b/packages/ui/jsonrpc/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-jsonrpc`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-jsonrpc/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-jsonrpc`. @module @deepseek-ai/dsh-jsonrpc/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc' @@ -17,8 +10,18 @@ export const name = 'jsonrpc-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'jsonrpc', + inject: [ + 'agents', + ], + effects: [ + 'jsonrpc.serve', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts index 1a774e2e4c..24191712bf 100644 --- a/packages/ui/permission/src/invariant.ts +++ b/packages/ui/permission/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-permission`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-permission/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-permission`. @module @deepseek-ai/dsh-permission/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-permission' @@ -17,8 +10,22 @@ export const name = 'permission-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'PermissionService', + inject: [ + 'bash', + 'approval', + ], + effects: [ + 'ctx.provide("permission")', + ], + services: [ + 'permission', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +34,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 50b630bfd1..cf08a0bafb 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -12,7 +12,12 @@ async function mounted(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise { const ctx = new Context() - ctx.provide('bash', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write' }) + ctx.provide('bash', { + sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write', + resolve() { throw new Error('permission tests do not execute bash') }, + run() { throw new Error('permission tests do not execute bash') }, + start() { throw new Error('permission tests do not execute bash') }, + }) ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } }) await ctx.plugin(PermissionService, options.config ?? {}) return ctx diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts index 443440a215..56bf12cda3 100644 --- a/packages/ui/stdio/src/invariant.ts +++ b/packages/ui/stdio/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-stdio/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-stdio`. @module @deepseek-ai/dsh-stdio/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-stdio' @@ -17,8 +10,20 @@ export const name = 'stdio-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'ui-stdio', + inject: [ + 'agents', + 'userInteraction', + ], + effects: [ + 'ctx.on("session/event")', + 'userInteraction.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/tool-ask-user/src/invariant.ts b/packages/ui/tool-ask-user/src/invariant.ts index eebb1ced42..2b18cad1f3 100644 --- a/packages/ui/tool-ask-user/src/invariant.ts +++ b/packages/ui/tool-ask-user/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-ask-user`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-ask-user/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-ask-user`. @module @deepseek-ai/dsh-tool-ask-user/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user' @@ -17,8 +10,19 @@ export const name = 'tool-ask-user-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-ask-user', + inject: [ + 'tools', + 'userInteraction', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/tui/src/invariant.ts b/packages/ui/tui/src/invariant.ts index cba5fd9d2e..d3014146d7 100644 --- a/packages/ui/tui/src/invariant.ts +++ b/packages/ui/tui/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tui`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tui/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tui`. @module @deepseek-ai/dsh-tui/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tui' @@ -17,8 +10,21 @@ export const name = 'tui-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'ui-tui', + inject: [ + 'agents', + 'userInteraction', + 'tools', + ], + effects: [ + 'ctx.on("session/event")', + 'userInteraction.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index d60b317965..11ec395d90 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -94,7 +94,7 @@ async function disposeSnapshot(harness: SnapshotHarness): Promise { async function configureAdvancedTools(ctx: Context): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry, { mode: 'code' }) - ctx.provide('workflows', {} as never) + ctx.provide('workflows', { start() {} } as never) await ctx.plugin(ToolWorkflow, { toolName: 'workflow', maxResultChars: 50_000 }) await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 }) } diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 73dee4f9f1..9a905be06f 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-user-approval`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-user-approval/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-user-approval`. @module @deepseek-ai/dsh-user-approval/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-approval' @@ -17,8 +10,19 @@ export const name = 'user-approval-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'ApprovalService', + effects: [ + 'ctx.provide("approval")', + 'ctx.on("agent/pre-step")', + ], + services: [ + 'approval', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +31,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/ui/user-interaction/src/invariant.ts b/packages/ui/user-interaction/src/invariant.ts index 262681fc06..85029b692b 100644 --- a/packages/ui/user-interaction/src/invariant.ts +++ b/packages/ui/user-interaction/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-user-interaction`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-user-interaction/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-user-interaction`. @module @deepseek-ai/dsh-user-interaction/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction' @@ -17,8 +10,18 @@ export const name = 'user-interaction-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'UserInteractionService', + effects: [ + 'ctx.provide("userInteraction")', + ], + services: [ + 'userInteraction', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index e932e7e35e..5ac98489cb 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-brand`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-brand/invariant - */ +/** Package-owned runtime contract for @deepseek-ai/dsh-brand. @module @deepseek-ai/dsh-brand/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-brand' @@ -17,8 +11,15 @@ export const name = 'brand-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert that the nominal-type primitive remains erased at runtime. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const brandRuntime = await import('./index.ts') + assertInvariant(fail, Object.keys(brandRuntime).length === 0, + 'the branded-id primitive must remain type-only with no runtime exports') + return () => {} + }, 'brand: validate type-only runtime erasure') +} /** * Register this package's invariant companion. diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts index 5874a57c1c..651d877e00 100644 --- a/packages/util/home/src/invariant.ts +++ b/packages/util/home/src/invariant.ts @@ -1,14 +1,9 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-home`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-home/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-home. @module @deepseek-ai/dsh-home/invariant */ /* jscpd:ignore-start */ +import { resolve } from 'node:path' import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-home' @@ -17,8 +12,19 @@ export const name = 'home-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert the canonical environment key and configured-path precedence. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') + const environmentKey: string = DSH_HOME_ENV + assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), + 'the canonical Harness home environment key must remain DSH_HOME') + const configured = 'relative-invariant-home' + assertInvariant(fail, resolveDshHome(configured) === resolve(configured), + 'an explicitly configured Harness home must normalize to an absolute path') + return () => {} + }, 'home: validate canonical DSH home resolution') +} /** * Register this package's invariant companion. diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index c2cedfbb0d..ce0ddb653c 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -1,14 +1,10 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-paths`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-paths/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-paths. @module @deepseek-ai/dsh-paths/invariant */ /* jscpd:ignore-start */ +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-paths' @@ -17,8 +13,19 @@ export const name = 'paths-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert tilde expansion and explicit-over-environment home precedence. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') + assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), + 'supported tilde prefixes must expand against the operating-system home') + const configured = 'relative-invariant-home' + const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) + assertInvariant(fail, resolved === resolve(configured), + 'an explicit DSH home must override the environment and normalize to an absolute path') + return () => {} + }, 'paths: validate DSH home resolution') +} /** * Register this package's invariant companion. diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index 7516f9e5ed..380e4e3d4b 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-retention`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-retention/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-retention. @module @deepseek-ai/dsh-retention/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-retention' @@ -17,8 +11,26 @@ export const name = 'retention-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert exact head-retention accounting after the budget is exceeded. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { ItemRetainer } = await import('./index.ts') + const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) + retainer.push('first') + retainer.push('second') + retainer.push('third') + const result = retainer.finish() + assertInvariant(fail, + result.items.join(',') === 'first,second' + && result.seen === 3 + && result.kept === 2 + && result.truncated + && result.omitted.kind === 'exact' + && result.omitted.count === 1, + 'head retention must keep the prefix and report exact seen, kept, and omitted counts') + return () => {} + }, 'retention: validate exact head accounting') +} /** * Register this package's invariant companion. diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index 15140bb880..8302380eaa 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -1,14 +1,8 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-timeout`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-timeout/invariant - */ +/** Package-owned runtime contracts for @deepseek-ai/dsh-timeout. @module @deepseek-ai/dsh-timeout/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout' @@ -17,8 +11,21 @@ export const name = 'timeout-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Assert default-before-cap arithmetic and capability-code classification. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.effect(async () => { + const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') + assertInvariant(fail, + clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, + 'timeout resolution must apply the default before capping and preserve smaller requests') + const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) + assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, + 'timeout classification must recover a matching capability-owned reason') + assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, + 'timeout classification must reject a reason owned by another capability') + return () => {} + }, 'timeout: validate resolution and reason classification') +} /** * Register this package's invariant companion. diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts index 008fe2f5e1..1cceb0f166 100644 --- a/packages/web/tool-web/src/invariant.ts +++ b/packages/web/tool-web/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-web`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-web/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-web`. @module @deepseek-ai/dsh-tool-web/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-web' @@ -17,8 +10,20 @@ export const name = 'tool-web-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-web', + inject: [ + 'tools', + 'web', + 'systemPrompt', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts index ef61e2a611..5a29395715 100644 --- a/packages/web/web-fetch-local/src/invariant.ts +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web-fetch-local`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-web-fetch-local/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web-fetch-local`. @module @deepseek-ai/dsh-web-fetch-local/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-fetch-local' @@ -17,8 +10,18 @@ export const name = 'web-fetch-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'web-fetch-local', + inject: [ + 'web', + ], + effects: [ + 'web.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts index 8781949be9..bfed9260dc 100644 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-deepseek`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-deepseek`. * @module @deepseek-ai/dsh-web-search-deepseek/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-deepseek' @@ -17,8 +13,18 @@ export const name = 'web-search-deepseek-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'web-search-deepseek', + inject: [ + 'web', + ], + effects: [ + 'web.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts index a2dd956625..8d5da8d433 100644 --- a/packages/web/web-search-exa/src/invariant.ts +++ b/packages/web/web-search-exa/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-exa`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-web-search-exa/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-exa`. @module @deepseek-ai/dsh-web-search-exa/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-exa' @@ -17,8 +10,18 @@ export const name = 'web-search-exa-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'web-search-exa', + inject: [ + 'web', + ], + effects: [ + 'web.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts index fe82c79dae..7669f6e53a 100644 --- a/packages/web/web-search-perplexity/src/invariant.ts +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web-search-perplexity`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-perplexity`. * @module @deepseek-ai/dsh-web-search-perplexity/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-perplexity' @@ -17,8 +13,18 @@ export const name = 'web-search-perplexity-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'web-search-perplexity', + inject: [ + 'web', + ], + effects: [ + 'web.registerProvider()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +33,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts index b9b1b0d45d..395679cf36 100644 --- a/packages/web/web/src/invariant.ts +++ b/packages/web/web/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-web`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-web/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web`. @module @deepseek-ai/dsh-web/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web' @@ -17,8 +10,18 @@ export const name = 'web-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'WebService', + effects: [ + 'ctx.provide("web")', + ], + services: [ + 'web', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +30,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index cd1f0e475b..966e4b3613 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-tool-workflow`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-tool-workflow/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-workflow`. @module @deepseek-ai/dsh-tool-workflow/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' @@ -17,8 +10,20 @@ export const name = 'tool-workflow-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'tool-workflow', + inject: [ + 'tools', + 'workflows', + 'systemPrompt', + ], + effects: [ + 'tools.register()', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +32,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts index 6bcc40862c..27c0865d79 100644 --- a/packages/workflow/workflow-workerthread/src/invariant.ts +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -1,14 +1,10 @@ /** - * Generated invariant ownership companion for `@deepseek-ai/dsh-workflow-workerthread`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts + * Package-owned runtime contract checks for `@deepseek-ai/dsh-workflow-workerthread`. * @module @deepseek-ai/dsh-workflow-workerthread/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-workerthread' @@ -17,8 +13,21 @@ export const name = 'workflow-workerthread-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Install checks for this package's active plugin fibers. */ +const install: InvariantInstaller = (ctx, fail) => { + observePluginInvariant(ctx, fail, { + name: 'WorkerWorkflowEngine', + inject: [ + 'subagents', + ], + effects: [ + 'ctx.provide("workflows")', + ], + services: [ + 'workflows', + ], + }) +} /** * Register this package's invariant companion. @@ -27,4 +36,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts index b552021ca4..a37d480e69 100644 --- a/packages/workflow/workflow/src/invariant.ts +++ b/packages/workflow/workflow/src/invariant.ts @@ -1,14 +1,7 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-workflow`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-workflow/invariant - */ +/** Package-owned runtime contract checks for `@deepseek-ai/dsh-workflow`. @module @deepseek-ai/dsh-workflow/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow' @@ -17,8 +10,12 @@ export const name = 'workflow-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} +/** Validate every implementation bound to this package's service seam. */ +const install: InvariantInstaller = (ctx, fail) => { + observeServiceInvariant(ctx, fail, 'workflows', value => serviceShapeViolation(value, { + methods: ['start'], + })) +} /** * Register this package's invariant companion. @@ -27,4 +24,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/scripts/gen-package-invariants.ts b/scripts/gen-package-invariants.ts deleted file mode 100644 index d30ce5a3eb..0000000000 --- a/scripts/gen-package-invariants.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Generate or verify package-owned invariant companion baselines. */ - -import { readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { - GENERATED_INVARIANT_MARKER, - collectPackageInvariantViolations, - formatPackageInvariantViolation, - packageInvariantOwners, - renderBaselineInvariant, -} from './package-invariants.ts' - -const root = resolve(import.meta.dirname, '..') -const check = process.argv.includes('--check') - -if (!check) { - let generated = 0 - for (const owner of packageInvariantOwners(root)) { - const path = resolve(root, owner.sourcePath) - let current: string | undefined - try { - current = readFileSync(path, 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - if (current !== undefined && !current.includes(GENERATED_INVARIANT_MARKER)) continue - const expected = renderBaselineInvariant(owner) - if (current === expected) continue - writeFileSync(path, expected) - generated += 1 - } - console.log(`gen-package-invariants: wrote ${generated} generated baseline companion(s).`) -} - -const violations = collectPackageInvariantViolations(root) -if (violations.length > 0) { - console.error('verify-package-invariants: violations found:') - for (const violation of violations) { - console.error(` ${formatPackageInvariantViolation(root, violation)}`) - } - process.exit(1) -} - -console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} package companion(s) conform.`) diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 698969b3d7..6ef9fce852 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -4,8 +4,6 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { collectPackageInvariantViolations, - packageInvariantOwners, - renderBaselineInvariant, } from './package-invariants.ts' const roots: string[] = [] @@ -14,6 +12,18 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) +function handwrittenInvariant(packageName: string): string { + return ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = (_ctx: unknown, fail: (message: string) => never) => { + if (typeof ${JSON.stringify(packageName)} !== 'string') fail('package name must remain a string') +} +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => + Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install)) +` +} + function fixture(options: { packageName?: string source?: string @@ -47,8 +57,7 @@ function fixture(options: { writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ references: options.invariantReference === false ? [] : [{ path: '../../support/invariants' }], }, null, 2)}\n`) - const owner = packageInvariantOwners(root)[0]! - writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? renderBaselineInvariant(owner)) + writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? handwrittenInvariant(packageName)) writeFileSync( join(dir, 'tsdown.config.ts'), options.buildEntry === false ? "export default { entry: ['lib/types/index.js'] }\n" : "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n", @@ -56,8 +65,43 @@ function fixture(options: { return root } +function addConformingPackage(root: string, slug: string, packageName: string, source: string): void { + const dir = join(root, `packages/core/${slug}`) + mkdirSync(join(dir, 'src'), { recursive: true }) + writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ + name: packageName, + exports: { + './invariant': { + types: './lib/types/invariant.d.ts', + default: './lib/invariant.js', + }, + }, + files: ['lib/invariant.js'], + peerDependencies: { '@deepseek-ai/dsh-invariants': '^0.0.1' }, + devDependencies: { '@deepseek-ai/dsh-invariants': 'workspace:^' }, + }, null, 2)}\n`) + writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ + references: [{ path: '../../support/invariants' }], + }, null, 2)}\n`) + writeFileSync(join(dir, 'src/invariant.ts'), source) + writeFileSync(join(dir, 'tsdown.config.ts'), "export default { entry: ['lib/types/invariant.js'] }\n") +} + +function nameObservedInvariant(packageName: string, pluginName: string): string { + return ` +import { observePluginInvariant } from '@deepseek-ai/dsh-invariants' +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = (ctx: never, fail: (message: string) => never) => { + observePluginInvariant(ctx, fail, { name: ${JSON.stringify(pluginName)} }) +} +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => + Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install)) +` +} + describe('package invariant gate', () => { - it('accepts a generated owner companion with publication metadata', () => { + it('accepts a hand-owned checking companion with publication metadata', () => { expect(collectPackageInvariantViolations(fixture())).toEqual([]) }) @@ -82,9 +126,10 @@ describe('package invariant gate', () => { export const name = 'probe-invariant' export const inject = ['invariants'] const selected = process.env.PACKAGE_NAME -export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => { - ctx.invariants.register('@deepseek-ai/dsh-foreign', () => {}) - return ctx.invariants.register(selected!, () => {}) +const install = (_ctx: unknown, fail: (message: string) => never) => { fail('probe') } +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => { + ctx.invariants.register('@deepseek-ai/dsh-foreign', install) + return ctx.invariants.register(selected!, install) } ` const violations = collectPackageInvariantViolations(fixture({ source })) @@ -94,11 +139,52 @@ export const apply = (ctx: { invariants: { register(name: string, install: () => ])) }) - it('rejects edits to a generated baseline', () => { - const root = fixture() - const path = join(root, 'packages/core/probe/src/invariant.ts') - writeFileSync(path, `${renderBaselineInvariant(packageInvariantOwners(root)[0]!)}// stale\n`) + it('rejects generated markers and empty or reporter-free installers', () => { + const generated = fixture({ + source: `/** @generated scripts/gen-package-invariants.ts */\n${handwrittenInvariant('@deepseek-ai/dsh-probe')}`, + }) + expect(collectPackageInvariantViolations(generated).map(violation => violation.message)) + .toContain('invariant companions must be hand-owned and may not carry @generated markers') + + const empty = fixture({ + source: ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = () => {} +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => + Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install)) +`, + }) + expect(collectPackageInvariantViolations(empty).map(violation => violation.message)) + .toEqual(expect.arrayContaining([ + 'install function must contain a package-owned invariant check', + 'install function must accept the bound failure reporter as its second parameter', + ])) + + const unused = fixture({ + source: ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = (_ctx: unknown, _fail: (message: string) => never) => { void 0 } +export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => + Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install)) +`, + }) + expect(collectPackageInvariantViolations(unused).map(violation => violation.message)) + .toContain('install function must use its bound failure reporter') + }) + + it('rejects duplicate name-based plugin observers across packages', () => { + const root = fixture({ + source: nameObservedInvariant('@deepseek-ai/dsh-probe', 'shared-runtime-name'), + }) + addConformingPackage( + root, + 'probe-two', + '@deepseek-ai/dsh-probe-two', + nameObservedInvariant('@deepseek-ai/dsh-probe-two', 'shared-runtime-name'), + ) expect(collectPackageInvariantViolations(root).map(violation => violation.message)) - .toContain('generated baseline is stale; run pnpm run gen-package-invariants') + .toContain('name-based plugin invariant "shared-runtime-name" is already owned by "@deepseek-ai/dsh-probe-two"') }) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 45fb64829f..6448888680 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -1,16 +1,13 @@ /** - * Package-invariant companion discovery, generation, and structural checks. + * Package-invariant companion discovery and structural checks. * The runtime registry stays product-independent; this gate makes ownership * exhaustive across packages without centralizing package checks. */ import { existsSync, globSync, readFileSync } from 'node:fs' -import { basename, dirname, relative, resolve, sep } from 'node:path' +import { dirname, relative, resolve, sep } from 'node:path' import ts from 'typescript' -/** Marker identifying baseline companions owned by this generator. */ -export const GENERATED_INVARIANT_MARKER = '@generated scripts/gen-package-invariants.ts' - interface PackageManifest { name?: string exports?: Record @@ -53,53 +50,26 @@ export function packageInvariantOwners(root: string): PackageInvariantOwner[] { }) } -/** Render the generated ownership-only companion for a package without custom checks. */ -export function renderBaselineInvariant(owner: PackageInvariantOwner): string { - const serviceImport = owner.packageName === '@deepseek-ai/dsh-invariants' - ? './index.ts' - : '@deepseek-ai/dsh-invariants' - const pluginName = `${basename(owner.dir)}-invariant` - return `/** - * Generated invariant ownership companion for \`${owner.packageName}\`. - * Replace this file with package-owned checks while preserving its registration. - * - * ${GENERATED_INVARIANT_MARKER} - * @module ${owner.packageName}/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '${serviceImport}' - -const PACKAGE_NAME = '${owner.packageName}' - -/** Cordis companion plugin name. */ -export const name = '${pluginName}' -/** Services required before the companion can register. */ -export const inject = ['invariants'] - -/** Reserve this package's invariant ownership until it adds relational checks. */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ -` -} - /** Return all violations of the package-invariant companion contract. */ export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { const violations: PackageInvariantViolation[] = [] + const observedPluginNames = new Map() for (const owner of packageInvariantOwners(root)) { const manifest = readManifest(resolve(root, owner.manifestPath)) checkManifest(owner, manifest, violations) checkBuild(owner, root, violations) - checkSource(owner, root, violations) + for (const pluginName of checkSource(owner, root, violations)) { + const existing = observedPluginNames.get(pluginName) + if (existing === undefined) { + observedPluginNames.set(pluginName, owner) + } else { + addViolation( + violations, + owner.sourcePath, + `name-based plugin invariant ${JSON.stringify(pluginName)} is already owned by ${JSON.stringify(existing.packageName)}`, + ) + } + } } return violations } @@ -181,19 +151,18 @@ function checkSource( owner: PackageInvariantOwner, root: string, violations: PackageInvariantViolation[], -): void { +): string[] { const absolutePath = resolve(root, owner.sourcePath) if (!existsSync(absolutePath)) { addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion') - return + return [] } const sourceText = readFileSync(absolutePath, 'utf8') - if (sourceText.includes(GENERATED_INVARIANT_MARKER) - && sourceText !== renderBaselineInvariant(owner)) { + if (sourceText.includes('@generated')) { addViolation( violations, owner.sourcePath, - 'generated baseline is stale; run pnpm run gen-package-invariants', + 'invariant companions must be hand-owned and may not carry @generated markers', ) } @@ -237,6 +206,87 @@ function checkSource( addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) } } + checkInstaller(owner, sourceFile, violations) + return nameOnlyObservedPlugins(sourceFile) +} + +function nameOnlyObservedPlugins(sourceFile: ts.SourceFile): string[] { + const names: string[] = [] + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) + && ts.isIdentifier(node.expression) + && node.expression.text === 'observePluginInvariant') { + const contract = node.arguments[2] + if (contract !== undefined && ts.isObjectLiteralExpression(contract)) { + let hasExactPlugin = false + let name: string | undefined + for (const property of contract.properties) { + if (!ts.isPropertyAssignment(property)) continue + const key = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) + ? property.name.text + : undefined + if (key === 'plugin') hasExactPlugin = true + if (key === 'name') name = stringValue(property.initializer, new Map()) + } + if (!hasExactPlugin && name !== undefined) names.push(name) + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return names +} + +function checkInstaller( + owner: PackageInvariantOwner, + sourceFile: ts.SourceFile, + violations: PackageInvariantViolation[], +): void { + let initializer: ts.Expression | undefined + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) + && declaration.name.text === 'install' + && declaration.initializer !== undefined) initializer = declaration.initializer + } + } + const installer = initializer === undefined ? undefined : installerFunction(initializer) + if (installer === undefined) { + addViolation(violations, owner.sourcePath, 'must declare a local install function for package-owned checks') + return + } + if (ts.isBlock(installer.body) && installer.body.statements.length === 0) { + addViolation(violations, owner.sourcePath, 'install function must contain a package-owned invariant check') + } + const reporter = installer.parameters[1]?.name + if (reporter === undefined || !ts.isIdentifier(reporter)) { + addViolation(violations, owner.sourcePath, 'install function must accept the bound failure reporter as its second parameter') + return + } + if (!usesIdentifier(installer.body, reporter.text)) { + addViolation(violations, owner.sourcePath, 'install function must use its bound failure reporter') + } +} + +function usesIdentifier(node: ts.Node, name: string): boolean { + return ts.isIdentifier(node) && node.text === name + || node.getChildren().some(child => usesIdentifier(child, name)) +} + +function installerFunction( + initializer: ts.Expression, +): ts.ArrowFunction | ts.FunctionExpression | undefined { + if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer + if (ts.isCallExpression(initializer) + && ts.isPropertyAccessExpression(initializer.expression) + && ts.isIdentifier(initializer.expression.expression) + && initializer.expression.expression.text === 'Object' + && initializer.expression.name.text === 'assign') { + const target = initializer.arguments[0] + if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target + } + return undefined } function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap { diff --git a/scripts/verify-package-invariants.ts b/scripts/verify-package-invariants.ts new file mode 100644 index 0000000000..32e34539fa --- /dev/null +++ b/scripts/verify-package-invariants.ts @@ -0,0 +1,21 @@ +/** Verify package-owned invariant source and publication contracts. */ + +import { resolve } from 'node:path' +import { + collectPackageInvariantViolations, + formatPackageInvariantViolation, + packageInvariantOwners, +} from './package-invariants.ts' + +const root = resolve(import.meta.dirname, '..') +const violations = collectPackageInvariantViolations(root) + +if (violations.length > 0) { + console.error('verify-package-invariants: violations found:') + for (const violation of violations) { + console.error(` ${formatPackageInvariantViolation(root, violation)}`) + } + process.exit(1) +} + +console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} hand-owned package companion(s) conform.`) diff --git a/website/zh-CN/api/harness/invariants.md b/website/zh-CN/api/harness/invariants.md index e55dd664c7..61589489f7 100644 --- a/website/zh-CN/api/harness/invariants.md +++ b/website/zh-CN/api/harness/invariants.md @@ -6,7 +6,7 @@ Package-owned invariant registry with global and regex-based selection. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L95) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L261) ### ctx.invariants.register(packageName, installer) @@ -29,4 +29,4 @@ Register one package's invariant installer. The package name is reserved even wh **Returns** an effect-scoped disposer for the registration. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L137) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L303) From caaa1364ec1b65fd39ebbdf2b184dcba3fb38f6a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:51:28 +0800 Subject: [PATCH 06/30] test(invariants): bound global companion topology --- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 4 +- ...7-19-package-owned-invariant-service.zh.md | 4 +- docs/testing.md | 2 +- packages/support/invariants/README.md | 2 +- scripts/test-invariants.spec.ts | 16 ++++- scripts/test-invariants.ts | 58 ++++++++++++++++--- 7 files changed, 72 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index 7bb90a9308..ef4090b3d6 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: d73cb196820369e41a748ad7a1c3c50b889d636f -2026-07-19-package-owned-invariant-service.zh.md: 644fd1f0668a097f028ec805c5cb425e536e277a +2026-07-19-package-owned-invariant-service.md: d7b77439c8cfdfdf2fc4f01e779a6d3c7f345457 +2026-07-19-package-owned-invariant-service.zh.md: b553d137f9f06b57f24672bf6984d087ca99ceb2 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index d73cb19682..d7b77439c8 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -84,7 +84,7 @@ Service tests cover defaults, global disablement, allow/block selection, blockli Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. -Every Vitest configuration loads a test host that mounts an explicitly enabled service and all package companions before an ordinary Cordis root's first plugin. Focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. +Every Vitest configuration loads a test host that mounts an explicitly enabled service before an ordinary Cordis root's first plugin. Package tests add their owner's companion, one exhaustive topology mounts every companion, and focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. ## Alternatives considered @@ -101,5 +101,5 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. -- Ordinary Vitest roots install every selected companion, trading extra child fibers during tests for repository-wide invariant coverage and immediate fixture failures. +- Ordinary Vitest roots install the current test package's companion; one exhaustive topology retains repository-wide registration coverage without multiplying every child fiber across every test root. - Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index 644fd1f066..b553d137f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -84,7 +84,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 -每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务以及所有包的伴随插件。服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 +每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务。包测试会添加其所有者的伴随插件,一个完整拓扑会挂载所有伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 ## 考虑过的替代方案 @@ -101,5 +101,5 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 -- 普通 Vitest 根上下文会安装每个被选中的伴随插件,以增加测试期间的子 fiber 为代价,换取覆盖整个仓库的不变式检查和对 fixture(测试前置数据)错误的即时反馈。 +- 普通 Vitest 根上下文会安装当前测试包的伴随插件;一个完整拓扑保留全仓库注册覆盖,而不会在每个测试根上下文中重复创建所有子 fiber。 - 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/docs/testing.md b/docs/testing.md index 3bf37fe79e..1fcefb4bc3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -All Vitest configurations mount enabled `ctx.invariants` and every package companion before ordinary Cordis roots start. Focused invariant topology tests compose enabled or deliberately disabled services explicitly, without competing global registrations. +Every Vitest configuration mounts enabled `ctx.invariants` before ordinary roots start. Package tests add their owner's companion; one exhaustive topology mounts them all. Focused invariant topology tests compose enabled or deliberately disabled services without competing global registrations. ## The with-key policy: inference is cheap here diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 11e3b2b5c6..7a5d0748e4 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -54,7 +54,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and the four stateful companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so baseline ownership and stateful checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. +The standard agent spine mounts the service and the four stateful companions. Custom compositions choose the companions they want and may disable or filter them without changing package entrypoints. Vitest gives every ordinary root an explicitly enabled service and mounts the current test package's companion; one exhaustive topology mounts all companions once, while focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. ## Model Experience diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 77150abb74..5c3b9120bc 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' -import { MANUAL_INVARIANT_TESTS, testInvariantCompanions } from './test-invariants.ts' +import { + MANUAL_INVARIANT_TESTS, + testInvariantCompanionPaths, + testInvariantCompanions, +} from './test-invariants.ts' declare module 'cordis' { interface Context { @@ -17,7 +21,7 @@ class TestInvariantProbe extends Service { } describe('global test invariant host', () => { - it('loads every companion and reserves every package name with enabled checks', async () => { + it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() await ctx.plugin(TestInvariantProbe) @@ -39,6 +43,14 @@ describe('global test invariant host', () => { expect(unreserved).toEqual([]) }) + it('mounts the owning package companion while leaving non-package roots service-only', () => { + expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts')) + .toEqual(['../packages/core/tools/src/invariant.ts']) + expect(testInvariantCompanionPaths('/repo/examples/echo-agent/tests/echo.spec.ts')).toEqual([]) + expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts')) + .toEqual(Object.keys(testInvariantCompanions).sort()) + }) + it('executes each companion registration with its owning package name', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 584db3d5d7..b91fdda462 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -1,7 +1,8 @@ /** * Vitest-wide invariant host. Ordinary Cordis roots receive the invariant - * service with global enablement and every package companion before their first - * plugin starts. Focused invariant tests own their service topology explicitly. + * service with global enablement plus the current test package's companion. + * One topology test mounts every companion; focused invariant tests own their + * service topology explicitly. */ import { expect } from 'vitest' @@ -40,6 +41,7 @@ export const MANUAL_INVARIANT_TESTS = [ interface InvariantHost { readonly fibers: readonly PluginFiber[] readonly byCallback: ReadonlyMap + readonly ready: Promise } type PluginFiber = ReturnType @@ -55,13 +57,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge const host = hosts.get(root) ?? startInvariantHost(root) const callback = this.resolve(plugin) const existing = callback === undefined ? undefined : host.byCallback.get(callback) - if (existing !== undefined) return existing + if (existing !== undefined) { + return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing + } const fiber = originalPlugin.call(this, plugin, config, getOuterStack) // A root-level await is the test's composition boundary. Nested plugin // fibers must not await their own companion parent through the global host. if (this.ctx !== root) return fiber - return joinInvariantStartup(fiber, host.fibers) + return joinInvariantStartup(fiber, host.ready) } function usesManualInvariantTree(): boolean { @@ -69,6 +73,30 @@ function usesManualInvariantTree(): boolean { return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path)) } +const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const + +/** + * Select the package companions that an ordinary test root must register. + * Package tests receive their owner's checks; the dedicated topology test + * receives every owner so coverage and exhaustive runtime registration remain + * independently enforced. + * @param testPath - absolute or repo-relative normalized Vitest file path. + * @returns sorted `import.meta.glob` keys for companions to mount. + */ +export function testInvariantCompanionPaths(testPath: string): string[] { + const normalized = testPath.replaceAll('\\', '/') + const allPaths = Object.keys(testInvariantCompanions).sort() + if (ALL_COMPANION_TESTS.some(path => normalized.endsWith(path))) return allPaths + + const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//) + if (owner === null) return [] + const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts` + if (testInvariantCompanions[companionPath] === undefined) { + throw new Error(`test invariants: package test has no companion at ${companionPath}`) + } + return [companionPath] +} + function startInvariantHost(root: Context): InvariantHost { const fibers: PluginFiber[] = [] const byCallback = new Map() @@ -81,21 +109,35 @@ function startInvariantHost(root: Context): InvariantHost { } mount(InvariantService, { enabled: true }) - for (const [path, companion] of Object.entries(testInvariantCompanions).sort(([left], [right]) => left.localeCompare(right))) { + const testPath = expect.getState().testPath ?? '' + const companionPaths = testInvariantCompanionPaths(testPath) + for (const path of companionPaths) { + const companion = testInvariantCompanions[path] + if (companion === undefined) { + throw new Error(`test invariants: selected companion vanished at ${path}`) + } if (!companion.inject.includes('invariants')) { throw new Error(`test invariants: ${path} must inject the invariant service`) } mount(companion) } - const host = { fibers, byCallback } + const [serviceFiber, ...companionFibers] = fibers + if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted') + // A companion is initially PENDING on the invariant service, and Cordis + // Fiber.await() only joins work already in flight. Wait for the service to + // activate its dependants before joining their startup and failures. + const ready = serviceFiber.await() + .then(() => Promise.all(companionFibers.map(fiber => fiber.await()))) + .then(() => undefined) + const host = { fibers, byCallback, ready } hosts.set(root, host) return host } -function joinInvariantStartup(fiber: PluginFiber, invariantFibers: readonly PluginFiber[]): PluginFiber { +function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { const readiness = fiber.await().then(async (loaded) => { - await Promise.all(invariantFibers.map(invariant => invariant.await())) + await invariantReady return loaded }) const joined = Object.create(fiber) as PluginFiber From e80fc3e61b64bd06e22f38a4d35691fa50dc6489 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:53:09 +0800 Subject: [PATCH 07/30] fix(invariants): join package checks at startup --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 14 +- ...-package-invariant-runtime-contracts.zh.md | 14 +- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 6 +- ...7-19-package-owned-invariant-service.zh.md | 6 +- docs/testing.md | 2 +- .../code-runtime/tests/service.spec.ts | 10 +- .../examples/jsonrpc-demo/src/invariant.ts | 11 +- packages/hooks/hook-protocol/src/invariant.ts | 29 ++- .../sandbox/sandbox/tests/invariant.spec.ts | 43 ++++ packages/sdk/create-sdk/src/invariant.ts | 35 ++- packages/sdk/helper/src/invariant.ts | 25 +- packages/sdk/scripts/src/invariant.ts | 29 +-- packages/sdk/telemetry/src/invariant.ts | 23 +- .../subagent-inprocess/src/invariant.ts | 15 +- .../subagent-subprocess/src/invariant.ts | 29 ++- .../support/acp-snapshot/src/invariant.ts | 35 ++- .../agent-loop-testkit/src/invariant.ts | 17 +- packages/support/invariants/README.md | 6 +- packages/support/invariants/src/index.ts | 229 ++++++++++++++---- .../support/invariants/tests/service.spec.ts | 93 ++++++- .../support/loader-smoke/src/invariant.ts | 31 ++- packages/ui/app-boot/src/invariant.ts | 21 +- packages/util/brand/src/invariant.ts | 11 +- packages/util/home/src/invariant.ts | 19 +- packages/util/paths/src/invariant.ts | 19 +- packages/util/retention/src/invariant.ts | 33 ++- packages/util/timeout/src/invariant.ts | 23 +- scripts/test-invariants.spec.ts | 16 +- scripts/test-invariants.ts | 58 ++++- 31 files changed, 596 insertions(+), 314 deletions(-) create mode 100644 packages/sandbox/sandbox/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 06d1f9e07e..48407e2272 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: d3b327694b0d6779409582a7bb199e9cd48a1e4c -2026-07-19-package-invariant-runtime-contracts.zh.md: 07c15e80587a292fc94e3537980ee26cbae15115 +2026-07-19-package-invariant-runtime-contracts.md: 65986fc0b3aab695d8512d9e221052e1db83445f +2026-07-19-package-invariant-runtime-contracts.zh.md: 0ae0bd305692ee71359c5500e477dee22575a9ef diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index d3b327694b..65986fc0b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -10,7 +10,7 @@ The package-owned invariant seam made registration and publication exhaustive, b Every package shape cannot use the same invariant. Cordis plugins own fibers, injections, effects, and services; service seams admit structural third-party implementations; stateful domains need event relations; pure libraries and bin packages expose algebra, parsing, normalization, or entrypoint constraints. The repository needs one enforceable obligation without moving those contracts back into a central product-aware package. -Vitest also mounts every companion globally. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. +Vitest mounts each package test's owning companion globally, and one exhaustive topology mounts every companion. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. ## Decision @@ -31,17 +31,17 @@ At implementation time this covers all 91 workspace packages: four stateful comp ### Product-independent observers -`observePluginInvariant` checks existing fibers immediately and future active fibers through global Cordis lifecycle events. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. +`observePluginInvariant` checks existing fibers immediately and future active fibers through a callback/name index behind one root-shared Cordis lifecycle listener pair. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. `observeServiceInvariant` checks the current service and every later binding. `serviceShapeViolation` validates callable members and non-empty string descriptors structurally instead of using `instanceof`, so conforming third-party backends and complete test doubles remain valid while incomplete stand-ins fail. -`assertInvariant` handles synchronous package algebra. Pure-package companions register an asynchronous child effect and dynamically import their owner inside that effect. This preserves atomic service-owned rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. +`assertInvariant` handles package algebra. Pure-package companions return an asynchronous installer promise and dynamically import their owner during child startup. The service joins that promise for atomic rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. ### Gate and test execution `verify-package-invariants` discovers every workspace package and retains the publication checks for the exact registration name, `./invariant` export, published files, invariant peer and development dependencies, TypeScript reference, and bundle entry. Its source check additionally parses the local `install` function, rejects a generated marker or empty body, requires a second failure-reporter parameter and its use, and rejects duplicate name-based plugin observers across packages. These AST checks are a minimum acceptance rule, not a claim that source shape proves semantic quality. -The Vitest setup host mounts `InvariantService` with `{ enabled: true }` and all 91 companions before an ordinary Cordis root's first plugin. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. Focused selection, lifecycle, and owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. +The Vitest setup host mounts `InvariantService` with `{ enabled: true }` before an ordinary Cordis root's first plugin and adds the current test package's companion. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. One exhaustive topology mounts all 91 companions once to prove runtime registration and coverage; focused selection, lifecycle, and owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. Helper tests reject invalid plugin names, missing injections, effects, services, custom relations, malformed service shapes, and failed assertions. Package suites then activate real plugins across their existing config and HMR paths. Test-only service stand-ins must implement the complete checked seam rather than bypass global invariants. @@ -50,7 +50,7 @@ Helper tests reject invalid plugin names, missing injections, effects, services, - **Keep generated ownership-only companions.** Rejected because registration without an executable assertion cannot reject a broken package and makes the exhaustive gate misleading. - **Generate one synthetic assertion into every package.** Rejected because a universal assertion would again optimize for satisfying the gate instead of protecting an owner-specific contract. - **Move the per-package contract matrix into `dsh-invariants`.** Rejected because product imports, vocabulary, and change ownership would return to the central service. -- **Import every owner entrypoint statically from its companion.** Rejected because the global test host would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. +- **Import every owner entrypoint statically from its companion.** Rejected because owning and exhaustive test hosts would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. - **Require first-party service-class identity.** Rejected because service seams are structural extension boundaries; `instanceof` would reject valid external implementations and test doubles. - **Register invariants implicitly from package root entrypoints.** Rejected for the composition-order and hidden-effect reasons in the package-owned service RFC. @@ -58,8 +58,8 @@ Helper tests reject invalid plugin names, missing injections, effects, services, - Every package contributes an executable check; adding a package without one fails the top-level gate. - The invariant service remains product-independent while providing reusable lifecycle and shape observers. -- Ordinary unit, snapshot, and e2e tests run with global invariant enablement and every companion registered. +- Ordinary unit, snapshot, and e2e roots run with global invariant enablement and the test package's companion; one exhaustive topology registers every companion. - Plugin names used for name-based observation must be unique within one Cordis root; packages may opt into exact callback identity when safe. - Pure-package checks sample stable startup contracts. Mutable behavior must use an event, service, or plugin-fiber observer. -- More companion work runs during tests and selected deployments, trading small startup cost for immediate package-attributed failures. +- Relevant companion work runs during package tests and selected deployments, trading bounded startup cost for immediate package-attributed failures. - The original regex selection, blocklist precedence, registration uniqueness, rollback, disposal, and HMR contracts remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 07c15e8058..0ae0bd3056 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -10,7 +10,7 @@ Status: implemented 不同包形态不能使用同一种不变式。Cordis 插件拥有 fiber、注入、effect 与服务;服务接缝允许结构兼容的第三方实现;有状态领域需要事件关系;纯库和 bin 包暴露代数、解析、规范化或入口约束。仓库需要一个可执行的统一义务,同时不能把这些契约重新移回了解产品语义的中央包。 -Vitest 还会全局挂载每个伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 +Vitest 会为每个包测试全局挂载其所有者伴随插件,并由一个完整拓扑挂载全部伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 ## 决策 @@ -31,17 +31,17 @@ Vitest 还会全局挂载每个伴随插件。因此伴随模块不能在测试 ### 与产品无关的观察器 -`observePluginInvariant` 会立即检查已有 fiber,并通过全局 Cordis 生命周期事件检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 +`observePluginInvariant` 会立即检查已有 fiber,并通过根上下文共享的一对 Cordis 生命周期监听器背后的 callback/名称索引检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 `observeServiceInvariant` 检查当前服务及之后的每次绑定。`serviceShapeViolation` 以结构方式验证可调用成员和非空字符串描述字段,而不使用 `instanceof`;因此符合契约的第三方后端和完整测试替身有效,不完整替身会失败。 -`assertInvariant` 处理同步包代数。纯包伴随插件注册异步子 effect,并在该 effect 内动态导入所有者。这样既保留服务拥有的原子回滚,又允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 +`assertInvariant` 处理包代数。纯包伴随插件返回异步 installer promise,并在子 fiber 启动期间动态导入所有者。服务会等待该 promise 以保证原子回滚,同时允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 ### 门禁与测试执行 `verify-package-invariants` 发现每个工作区包,并保留准确注册名、`./invariant` export、发布文件、不变式 peer 与开发依赖、TypeScript 引用和 bundle 入口的发布检查。源码检查还会解析本地 `install` 函数,拒绝生成标记或空函数体,要求第二个失败报告器参数及其使用,并拒绝跨包重复的按名称插件观察器。这些 AST 检查只是最低接收规则,并不宣称源码形状足以证明语义质量。 -Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 91 个伴随插件,然后才启动普通 Cordis 根上下文的第一个插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。选择、生命周期和所有者聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 +Vitest setup host 会在普通 Cordis 根上下文启动第一个插件前,以 `{ enabled: true }` 挂载 `InvariantService`,并添加当前测试包的伴随插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。一个完整拓扑会一次挂载全部 91 个伴随插件,以证明运行时注册与覆盖率;选择、生命周期和所有者聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 辅助测试会拒绝错误插件名、缺失注入、effect、服务或自定义关系、错误服务形状和失败断言。随后,包套件在已有配置与 HMR 路径上激活真实插件。测试专用服务替身必须实现完整的已检查接缝,不能绕过全局不变式。 @@ -50,7 +50,7 @@ Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 - **保留生成的仅声明所有权伴随插件。** 不予采纳,因为没有可执行断言的注册无法拒绝损坏的包,也会让完整门禁产生误导。 - **为每个包生成一个合成断言。** 不予采纳,因为通用断言仍是在优化如何通过门禁,而不是保护所有者专属契约。 - **把逐包契约矩阵移入 `dsh-invariants`。** 不予采纳,因为产品导入、词汇和变更所有权会重新回到中央服务。 -- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为全局测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 +- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为所有者测试 host 与完整测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 - **要求第一方服务类身份。** 不予采纳,因为服务接缝是结构化扩展边界;`instanceof` 会拒绝有效的外部实现和测试替身。 - **从包根入口隐式注册不变式。** 因包拥有服务 RFC 中的组合顺序与隐藏 effect 问题而不予采纳。 @@ -58,8 +58,8 @@ Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 - 每个包都贡献可执行检查;新增包若没有检查,会在顶层门禁失败。 - 不变式服务保持与产品无关,同时提供可复用的生命周期与形状观察器。 -- 普通单元、snapshot 与 e2e 测试均全局启用不变式并注册每个伴随插件。 +- 普通单元、snapshot 与 e2e 根上下文均全局启用不变式并注册当前测试包的伴随插件;一个完整拓扑注册全部伴随插件。 - 用于按名称观察的插件名在一个 Cordis 根上下文内必须唯一;安全时包可以选择准确 callback 身份。 - 纯包检查对稳定启动契约采样;可变行为必须使用事件、服务或插件 fiber 观察器。 -- 测试和被选部署会执行更多伴随工作,以少量启动成本换取即时且带包归属的失败。 +- 包测试和被选部署会执行相关伴随工作,以有界启动成本换取即时且带包归属的失败。 - 原有正则选择、blocklist 优先级、注册唯一性、回滚、dispose 与 HMR 契约保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index 8e83577949..9ab8642f4b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: 3ffba4ff849b6e4be731586dc7d1b37f4c1a76eb -2026-07-19-package-owned-invariant-service.zh.md: d8be195eccb1ab53439b6cdb90b725ae0889c8a6 +2026-07-19-package-owned-invariant-service.md: 552b088c1cafc2fa762487f57fa1d4ad64390f7e +2026-07-19-package-owned-invariant-service.zh.md: 84bdc6a6a3baefe95713f8ce5b8a4e2af49b64eb diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index 3ffba4ff84..552b088c1c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -49,7 +49,7 @@ Blocklist matches override allowlist matches. Each list entry is a case-sensitiv The public registration boundary is `ctx.invariants.register(packageName, installer)`. It reserves one active registration per full npm package name even when filters disable installation, and returns the effect disposer. Disposing the companion or service releases the reservation and all contribution state. -An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. +An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The service joins a returned installer promise before registration succeeds, so asynchronous startup checks remain transactional. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. Registration setup is transactional. If an installer fails after registering listeners, the child fiber is disposed completely and the name reservation is released before the failure escapes. Filtered registrations create no child but retain their reservation until disposal. Reloading a companion therefore begins with one clean installer state; stateful contributions rebuild baselines from their owning services. @@ -84,7 +84,7 @@ Service tests cover defaults, global disablement, allow/block selection, blockli Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. -Every Vitest configuration loads a test host that mounts an explicitly enabled service and all package companions before an ordinary Cordis root's first plugin. Focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. +Every Vitest configuration loads a test host that mounts an explicitly enabled service before an ordinary Cordis root's first plugin and adds the current test package's companion. One exhaustive topology mounts all package companions once; focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. ## Alternatives considered @@ -101,5 +101,5 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. -- Ordinary Vitest roots install every selected companion, trading extra child fibers during tests for repository-wide invariant coverage and immediate fixture failures. +- Ordinary Vitest roots install the owning test package's selected companion; one exhaustive topology pays the full child-fiber cost once for repository-wide registration coverage. - Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index d8be195ecc..84bdc6a6a3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -49,7 +49,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 公开注册边界是 `ctx.invariants.register(packageName, installer)`。即使过滤器禁止安装,它也会为每个完整 npm 包名保留唯一的活跃注册,并返回 effect disposer。卸载伴随插件或服务都会释放注册名及全部贡献状态。 -启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 +启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。服务会在注册成功前等待 installer 返回的 promise,因此异步启动检查仍具有事务性。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 注册启动是事务性的。如果 installer 在注册监听器后失败,子 fiber 会完整释放,并在失败向外传播前解除包名占用。被过滤的注册不创建子 fiber,但会保留占用直到 dispose。伴随插件重载时总会从干净的 installer 状态开始;有状态贡献从其所属服务重建基线。 @@ -84,7 +84,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 -每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务以及所有包的伴随插件。服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 +每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务,并添加当前测试包的伴随插件。一个完整拓扑会一次挂载所有包的伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 ## 考虑过的替代方案 @@ -101,5 +101,5 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 -- 普通 Vitest 根上下文会安装每个被选中的伴随插件,以增加测试期间的子 fiber 为代价,换取覆盖整个仓库的不变式检查和对 fixture(测试前置数据)错误的即时反馈。 +- 普通 Vitest 根上下文会安装当前测试包中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。 - 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/docs/testing.md b/docs/testing.md index 3bf37fe79e..1fcefb4bc3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -All Vitest configurations mount enabled `ctx.invariants` and every package companion before ordinary Cordis roots start. Focused invariant topology tests compose enabled or deliberately disabled services explicitly, without competing global registrations. +Every Vitest configuration mounts enabled `ctx.invariants` before ordinary roots start. Package tests add their owner's companion; one exhaustive topology mounts them all. Focused invariant topology tests compose enabled or deliberately disabled services without competing global registrations. ## The with-key policy: inference is cheap here diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 01736ea0ab..68bc809de7 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import { InvariantError } from '@deepseek-ai/dsh-invariants' /** * Minimal concrete runtime: records requests, "executes" by invoking every @@ -96,6 +97,13 @@ describe('CodeRuntime service seam', () => { child.provide('codeRuntime', value as unknown as CodeRuntime) }, } - await expect(ctx.plugin(invalidRuntime)).rejects.toThrow(message) + let caught: unknown + try { + await ctx.plugin(invalidRuntime) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(InvariantError) + expect((caught as Error).message).toMatch(message) }) }) diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index f65eee8165..a9352d02f8 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -12,13 +12,10 @@ export const name = 'jsonrpc-demo-invariant' export const inject = ['invariants'] /** Assert that Loader configuration, rather than a hidden root plugin, owns composition. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const packageEntry = await import('./index.ts') - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') - return () => {} - }, 'jsonrpc-demo: validate bin-only entrypoint') +const install: InvariantInstaller = async (_ctx, fail) => { + const packageEntry = await import('./index.ts') + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') } /** diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 6503791f1c..997dd75b30 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -12,22 +12,21 @@ export const name = 'hook-protocol-invariant' export const inject = ['invariants'] /** Assert blocking-exit decoding and restrictive merge precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { parseHookOutput } = await import('./codec.ts') - const { mergeHookOutputs } = await import('./merge.ts') - const blocked = parseHookOutput(2, '', ' denied ') - assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', - 'exit 2 must decode as a block whose reason is trimmed stderr') +const install: InvariantInstaller = async (_ctx, fail) => { + const [{ parseHookOutput }, { mergeHookOutputs }] = await Promise.all([ + import('./codec.ts'), + import('./merge.ts'), + ]) + const blocked = parseHookOutput(2, '', ' denied ') + assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', + 'exit 2 must decode as a block whose reason is trimmed stderr') - const merged = mergeHookOutputs([ - { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, - { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, - ]) - assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', - 'deny must override allow and retain only the winning decision reason') - return () => {} - }, 'hook-protocol: validate decode and merge algebra') + const merged = mergeHookOutputs([ + { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, + { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, + ]) + assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', + 'deny must override allow and retain only the winning decision reason') } /** diff --git a/packages/sandbox/sandbox/tests/invariant.spec.ts b/packages/sandbox/sandbox/tests/invariant.spec.ts new file mode 100644 index 0000000000..24854b56ae --- /dev/null +++ b/packages/sandbox/sandbox/tests/invariant.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { InvariantError } from '@deepseek-ai/dsh-invariants' +import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' + +class StubSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { + argv: [...argv], + enforcement: 'full', + denialSignatures: [], + runnerFailureSignatures: [], + } + } +} + +describe('sandbox package invariant', () => { + it('accepts a provider that exposes the confinement seam', async () => { + const ctx = new Context() + await ctx.plugin(StubSandboxProvider) + expect(ctx.sandbox).toBeInstanceOf(StubSandboxProvider) + }) + + it('rejects a service binding without confine()', async () => { + const ctx = new Context() + const invalidSandbox = { + name: 'invalid-sandbox', + apply(child: Context) { + child.provide('sandbox', {} as SandboxProvider) + }, + } + let caught: unknown + try { + await ctx.plugin(invalidSandbox) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(InvariantError) + expect(caught).toHaveProperty('packageName', '@deepseek-ai/dsh-sandbox') + expect((caught as Error).message).toMatch(/must expose method "confine"/) + }) +}) diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts index 66a95e6a38..417d936128 100644 --- a/packages/sdk/create-sdk/src/invariant.ts +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -12,24 +12,23 @@ export const name = 'create-sdk-invariant' export const inject = ['invariants'] /** Assert the bin-only entrypoint and its core argument mapping. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { parseCreateArgs } = await import('./args.ts') - const packageEntry = await import('./index.ts') - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the create-sdk library entrypoint must remain empty because the package is bin-only') - const parsed = parseCreateArgs([ - 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', - ]) - assertInvariant(fail, - parsed.directory === 'workspace' - && parsed.provider === 'custom' - && parsed.baseURL === 'https://example.test' - && parsed.runInterface === 'embed' - && parsed.install === false, - 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') - return () => {} - }, 'create-sdk: validate bin and argument contracts') +const install: InvariantInstaller = async (_ctx, fail) => { + const [{ parseCreateArgs }, packageEntry] = await Promise.all([ + import('./args.ts'), + import('./index.ts'), + ]) + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the create-sdk library entrypoint must remain empty because the package is bin-only') + const parsed = parseCreateArgs([ + 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', + ]) + assertInvariant(fail, + parsed.directory === 'workspace' + && parsed.provider === 'custom' + && parsed.baseURL === 'https://example.test' + && parsed.runInterface === 'embed' + && parsed.install === false, + 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') } /** diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts index e91fb012d5..5faf1ea49c 100644 --- a/packages/sdk/helper/src/invariant.ts +++ b/packages/sdk/helper/src/invariant.ts @@ -12,20 +12,17 @@ export const name = 'helper-invariant' export const inject = ['invariants'] /** Assert FeatureId's zero-cost representation and boundary validation. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { featureId } = await import('./ids.ts') - assertInvariant(fail, featureId('local-plugin') === 'local-plugin', - 'a valid feature id must preserve its runtime string value') - let rejected = false - try { - featureId('Invalid Feature') - } catch (error) { - rejected = error instanceof Error - } - assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') - return () => {} - }, 'dsh-helper: validate feature identities') +const install: InvariantInstaller = async (_ctx, fail) => { + const { featureId } = await import('./ids.ts') + assertInvariant(fail, featureId('local-plugin') === 'local-plugin', + 'a valid feature id must preserve its runtime string value') + let rejected = false + try { + featureId('Invalid Feature') + } catch (error) { + rejected = error instanceof Error + } + assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') } /** diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts index 316e7ebed1..522791fe3b 100644 --- a/packages/sdk/scripts/src/invariant.ts +++ b/packages/sdk/scripts/src/invariant.ts @@ -12,22 +12,19 @@ export const name = 'scripts-invariant' export const inject = ['invariants'] /** Assert the launcher's opaque post-separator forwarding boundary. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { splitForwardedArgs } = await import('./forwarding.ts') - const plain = splitForwardedArgs(['dev', 'src/index.ts']) - const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) - assertInvariant(fail, - plain.launcher.length === 2 - && plain.forwarded.length === 0 - && separated.launcher.length === 2 - && separated.launcher[1] === 'src/index.ts' - && separated.forwarded.length === 2 - && separated.forwarded[0] === '--inspect' - && separated.forwarded[1] === '9229', - 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') - return () => {} - }, 'dsh-sdk: validate command argument contracts') +const install: InvariantInstaller = async (_ctx, fail) => { + const { splitForwardedArgs } = await import('./forwarding.ts') + const plain = splitForwardedArgs(['dev', 'src/index.ts']) + const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) + assertInvariant(fail, + plain.launcher.length === 2 + && plain.forwarded.length === 0 + && separated.launcher.length === 2 + && separated.launcher[1] === 'src/index.ts' + && separated.forwarded.length === 2 + && separated.forwarded[0] === '--inspect' + && separated.forwarded[1] === '9229', + 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') } /** diff --git a/packages/sdk/telemetry/src/invariant.ts b/packages/sdk/telemetry/src/invariant.ts index 8333b1ff71..9ec704b672 100644 --- a/packages/sdk/telemetry/src/invariant.ts +++ b/packages/sdk/telemetry/src/invariant.ts @@ -12,19 +12,16 @@ export const name = 'telemetry-invariant' export const inject = ['invariants'] /** Assert the final telemetry redaction boundary removes secrets without corrupting ordinary package metadata. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const [{ DEFAULT_REDACTION_PLACEHOLDER, SecretRedactor }, { telemetryRedactionViolation }] = await Promise.all([ - import('./secret-redactor.ts'), - import('./redaction-contract.ts'), - ]) - const redactor = new SecretRedactor() - const violation = telemetryRedactionViolation(redactor, DEFAULT_REDACTION_PLACEHOLDER, PACKAGE_NAME) - assertInvariant(fail, - violation === undefined, - violation ?? 'telemetry redaction contract failed without a diagnostic') - return () => {} - }, 'telemetry: validate secret-redaction boundary') +const install: InvariantInstaller = async (_ctx, fail) => { + const [{ telemetryRedactionViolation }, { DEFAULT_REDACTION_PLACEHOLDER, SecretRedactor }] = await Promise.all([ + import('./redaction-contract.ts'), + import('./secret-redactor.ts'), + ]) + const redactor = new SecretRedactor() + const violation = telemetryRedactionViolation(redactor, DEFAULT_REDACTION_PLACEHOLDER, PACKAGE_NAME) + assertInvariant(fail, + violation === undefined, + violation ?? 'telemetry redaction contract failed without a diagnostic') } /** diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index a007200d73..caaf598f55 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -12,15 +12,12 @@ export const name = 'subagent-inprocess-invariant' export const inject = ['invariants'] /** Assert that structured-output guidance names the tool it actually installs. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') - assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), - 'the structured-output tool must retain a stable lowercase protocol name') - assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), - 'the structured-output instruction must name the exact installed tool') - return () => {} - }, 'subagent-inprocess: validate structured-output protocol') +const install: InvariantInstaller = async (_ctx, fail) => { + const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') + assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), + 'the structured-output tool must retain a stable lowercase protocol name') + assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), + 'the structured-output instruction must name the exact installed tool') } /** diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index ed29ecc855..9eed9ae9e3 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -13,19 +13,24 @@ export const name = 'subagent-subprocess-invariant' export const inject = ['invariants'] /** Assert ambient credential scrubbing and explicit credential precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { buildChildEnv } = await import('./index.ts') - const scrubbed = buildChildEnv({}) - const ambientSensitiveNames = Object.keys(process.env).filter(key => SENSITIVE_ENV_PATTERN.test(key)) - assertInvariant(fail, ambientSensitiveNames.every(key => !Object.hasOwn(scrubbed, key)), - 'subprocess environments must omit every credential-shaped ambient variable') +const install: InvariantInstaller = async (_ctx, fail) => { + const { buildChildEnv } = await import('./index.ts') + const ambientProbe = `DSH_INVARIANT_AMBIENT_TOKEN_${process.pid}` + assertInvariant(fail, SENSITIVE_ENV_PATTERN.test(ambientProbe), + 'the invariant ambient probe must remain credential-shaped') + process.env[ambientProbe] = 'must-not-reach-child' + let scrubbed: NodeJS.ProcessEnv + try { + scrubbed = buildChildEnv({}) + } finally { + Reflect.deleteProperty(process.env, ambientProbe) + } + assertInvariant(fail, !Object.hasOwn(scrubbed, ambientProbe), + 'subprocess environments must omit every credential-shaped ambient variable') - const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) - assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', - 'explicit child credentials must be applied after ambient scrubbing') - return () => {} - }, 'subagent-subprocess: validate child environment isolation') + const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) + assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', + 'explicit child credentials must be applied after ambient scrubbing') } /** diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index fc568d7b37..ac29b83cff 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -12,25 +12,22 @@ export const name = 'acp-snapshot-invariant' export const inject = ['invariants'] /** Assert stable JSON-RPC correlation and volatile-value tokenization. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { normalizeStdout } = await import('./normalize.ts') - const sessionId = '12345678-1234-1234-1234-123456789abc' - const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } - const raw = [ - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), - ].join('\n') - const normalized = normalizeStdout(raw, volatile) - assertInvariant(fail, - normalized.includes('"id":1') - && normalized.includes('"cwd":"{{cwd}}"') - && normalized.includes('"sessionId":"{{sessionId}}"'), - 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') - assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, - 'ACP stdout normalization must be idempotent') - return () => {} - }, 'acp-snapshot: validate stable transcript normalization') +const install: InvariantInstaller = async (_ctx, fail) => { + const { normalizeStdout } = await import('./normalize.ts') + const sessionId = '12345678-1234-1234-1234-123456789abc' + const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } + const raw = [ + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), + ].join('\n') + const normalized = normalizeStdout(raw, volatile) + assertInvariant(fail, + normalized.includes('"id":1') + && normalized.includes('"cwd":"{{cwd}}"') + && normalized.includes('"sessionId":"{{sessionId}}"'), + 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') + assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, + 'ACP stdout normalization must be idempotent') } /** diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index fd72038d3f..69792b77ea 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -12,16 +12,13 @@ export const name = 'agent-loop-testkit-invariant' export const inject = ['invariants'] /** Assert the awaitable helper shape and optional-options call boundary. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { mountAgentLoopTestDependencies } = await import('./index.ts') - assertInvariant(fail, - mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', - 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') - assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, - 'the prerequisite mount helper must keep its options argument optional') - return () => {} - }, 'agent-loop-testkit: validate prerequisite mount boundary') +const install: InvariantInstaller = async (_ctx, fail) => { + const { mountAgentLoopTestDependencies } = await import('./index.ts') + assertInvariant(fail, + mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', + 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') + assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, + 'the prerequisite mount helper must keep its options argument optional') } /** diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index eb26f57e43..7b922bcda9 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -16,7 +16,7 @@ Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: [ Each entry is a case-sensitive JavaScript regular-expression source compiled with `new RegExp(pattern)`. Matching is unanchored unless the source supplies `^` and `$`; `/pattern/flags` syntax is not parsed. Blank, whitespace-padded, invalid, or duplicate entries within one list fail service startup. A valid pattern may match no currently loaded package so later loading and HMR remain deterministic. -`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Installer failure disposes the child and releases ownership atomically. +`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Synchronous or asynchronous installer completion is joined before registration succeeds; failure disposes the child and releases ownership atomically. The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes the listeners and reservation completely. A companion can therefore reload and register the same package name without retaining trace state or duplicate listeners; packages that need an existing baseline rebuild it during installation. @@ -32,7 +32,7 @@ Packages select the narrowest runtime form that protects their public contract: |---|---| | Cordis plugin | `observePluginInvariant` validates the plugin's own declared name, required injections, owned effect group, provided services, and optional package-specific relation for existing, late, and HMR-activated fibers. | | Cordis service seam | `observeServiceInvariant` plus `serviceShapeViolation` validates current and future structural implementations, including conforming third-party backends and test doubles. | -| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape in a child effect. | +| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape during child startup. | Four companions additionally install stateful event and request checks: @@ -62,7 +62,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so all package checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. +The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Plugin and service helpers multiplex package contracts through indexed lifecycle listeners shared by the Cordis root, while contribution disposal removes only that owner's contract. Vitest gives every ordinary root an explicitly enabled service and mounts the current test package's companion; one exhaustive topology test mounts all companions once, and focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. ## Model Experience diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 374012fdbd..265596b677 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -28,15 +28,15 @@ export interface Config { */ export type InvariantFailure = (message: string) => never -/** Install one package's listeners into the registration's child context. */ +/** Install one package's checks into the registration's child context. */ export interface InvariantInstaller { /** * Install the package contribution. * @param ctx - child context owned by this invariant registration. * @param fail - reporter bound to the registering package name. - * @returns nothing after synchronous listener installation completes. + * @returns nothing, or a promise settling after asynchronous checks finish. */ - (ctx: Context, fail: InvariantFailure): void + (ctx: Context, fail: InvariantFailure): void | Promise /** Services the child installer fiber may access. */ readonly inject?: Inject } @@ -70,11 +70,111 @@ function collectEffectLabels(fiber: Fiber): ReadonlySet { return labels } +/** One package check routed by a root-shared plugin lifecycle dispatcher. */ +interface PluginObservation { + readonly callback: globalThis.Function | undefined + readonly contract: PluginInvariantContract + readonly fail: InvariantFailure +} + +/** Indexed plugin checks and the two lifecycle listeners shared by one root. */ +interface PluginObservationHub { + readonly byCallback: Map> + readonly byName: Map> +} + +const pluginObservationHubs = new WeakMap() + +/** Check one already-matched active plugin fiber. */ +function inspectPluginObservation(observation: PluginObservation, fiber: Fiber): void { + if (fiber.state !== FiberState.ACTIVE || fiber.uid === null) return + const { callback, contract, fail } = observation + if (callback !== undefined && fiber.name !== contract.name) { + fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) + } + const injections = new Set(Object.keys(fiber.inject)) + for (const service of contract.inject ?? []) { + if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) + } + + const effectLabels = collectEffectLabels(fiber) + for (const requirement of contract.effects ?? []) { + const alternatives = typeof requirement === 'string' ? [requirement] : requirement + if (!alternatives.some(label => effectLabels.has(label))) { + fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) + } + } + for (const service of contract.services ?? []) { + const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { + const implementation = fiber.ctx.reflect.store[key as symbol] + return implementation?.fiber === fiber && implementation.name === service + }) + if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) + } + const message = contract.validate?.(fiber, effectLabels) + if (message !== undefined) fail(message) +} + +/** Route one lifecycle notification only to checks that can match its runtime. */ +function inspectObservedPlugin(hub: PluginObservationHub, fiber: Fiber): void { + const callback = fiber.runtime?.callback + if (callback !== undefined) { + for (const observation of hub.byCallback.get(callback) ?? []) { + inspectPluginObservation(observation, fiber) + } + } + const runtimeName = fiber.runtime?.name + if (runtimeName !== undefined) { + for (const observation of hub.byName.get(runtimeName) ?? []) { + inspectPluginObservation(observation, fiber) + } + } +} + +/** Return the root's shared plugin dispatcher, creating its two listeners once. */ +function pluginObservationHub(ctx: Context): PluginObservationHub { + const root = ctx.root + const existing = pluginObservationHubs.get(root) + if (existing !== undefined) return existing + + const hub: PluginObservationHub = { + byCallback: new Map(), + byName: new Map(), + } + pluginObservationHubs.set(root, hub) + root.on('internal/plugin', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) + root.on('internal/status', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) + return hub +} + +/** Add one plugin observation to a typed exact-key index. */ +function addIndexedPluginObservation( + index: Map>, + key: Key, + observation: PluginObservation, +): () => void { + const observations = index.get(key) ?? new Set() + index.set(key, observations) + observations.add(observation) + return () => { + observations.delete(observation) + if (observations.size === 0) index.delete(key) + } +} + +/** Add one observation to its exact callback or runtime-name index. */ +function addPluginObservation(hub: PluginObservationHub, observation: PluginObservation): () => void { + if (observation.callback === undefined) { + return addIndexedPluginObservation(hub.byName, observation.contract.name, observation) + } + return addIndexedPluginObservation(hub.byCallback, observation.callback, observation) +} + /** * Observe one package plugin and fail whenever an active fiber violates its * declared name, dependency, effect, service, or package-specific contract. * Existing fibers are checked immediately; later starts and HMR activations - * are checked through Cordis lifecycle events. + * are checked through two indexed lifecycle listeners shared by the root. * @param ctx - invariant child context that owns the observers. * @param fail - reporter bound to the package that owns the plugin. * @param contract - expected runtime facts for the package plugin. @@ -90,50 +190,79 @@ export function observePluginInvariant( fail('invariant contract does not identify a Cordis plugin') } - const inspect = (fiber: Fiber): void => { - const matches = callback === undefined - ? fiber.runtime?.name === contract.name - : fiber.runtime?.callback === callback - if (!matches || fiber.state !== FiberState.ACTIVE) return - if (callback !== undefined && fiber.name !== contract.name) { - fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) - } - const injections = new Set(Object.keys(fiber.inject)) - for (const service of contract.inject ?? []) { - if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) - } - - const effectLabels = collectEffectLabels(fiber) - for (const requirement of contract.effects ?? []) { - const alternatives = typeof requirement === 'string' ? [requirement] : requirement - if (!alternatives.some(label => effectLabels.has(label))) { - fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) - } - } - for (const service of contract.services ?? []) { - const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { - const implementation = fiber.ctx.reflect.store[key as symbol] - return implementation?.fiber === fiber && implementation.name === service - }) - if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) - } - const message = contract.validate?.(fiber, effectLabels) - if (message !== undefined) fail(message) - } + const observation: PluginObservation = { callback, contract, fail } if (contract.plugin === undefined) { for (const runtime of ctx.registry.values()) { - for (const fiber of runtime.fibers) inspect(fiber) + if (runtime.name !== contract.name) continue + for (const fiber of runtime.fibers) inspectPluginObservation(observation, fiber) } } else { - for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) inspect(fiber) + for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) { + inspectPluginObservation(observation, fiber) + } + } + const hub = pluginObservationHub(ctx) + ctx.effect( + () => addPluginObservation(hub, observation), + `invariants.observePlugin(${JSON.stringify(contract.name)})`, + ) +} + +/** One structural check routed by a root-shared service lifecycle dispatcher. */ +interface ServiceObservation { + readonly fail: InvariantFailure + readonly validate: (value: unknown) => string | undefined +} + +/** Service checks and the single service listener shared by one root. */ +interface ServiceObservationHub { + readonly byName: Map> +} + +const serviceObservationHubs = new WeakMap() + +/** Check one present service implementation. */ +function inspectServiceObservation(observation: ServiceObservation, value: unknown): void { + if (value === undefined) return + const message = observation.validate(value) + if (message !== undefined) observation.fail(message) +} + +/** Return the root's shared service dispatcher, creating its listener once. */ +function serviceObservationHub(ctx: Context): ServiceObservationHub { + const root = ctx.root + const existing = serviceObservationHubs.get(root) + if (existing !== undefined) return existing + + const hub: ServiceObservationHub = { byName: new Map() } + serviceObservationHubs.set(root, hub) + root.on('internal/service', (name, value: unknown) => { + for (const observation of hub.byName.get(name) ?? []) { + inspectServiceObservation(observation, value) + } + }, { global: true }) + return hub +} + +/** Add one service observation to its exact service-name index. */ +function addServiceObservation( + hub: ServiceObservationHub, + serviceName: string, + observation: ServiceObservation, +): () => void { + const observations = hub.byName.get(serviceName) ?? new Set() + hub.byName.set(serviceName, observations) + observations.add(observation) + return () => { + observations.delete(observation) + if (observations.size === 0) hub.byName.delete(serviceName) } - ctx.on('internal/plugin', inspect, { global: true }) - ctx.on('internal/status', inspect, { global: true }) } /** - * Validate every current and future implementation bound to one Cordis service. + * Validate every current and future implementation bound to one Cordis + * service through the root's indexed shared service listener. * @param ctx - invariant child context that owns the service observer. * @param fail - reporter bound to the package that owns the service seam. * @param serviceName - Cordis service name to observe. @@ -146,16 +275,14 @@ export function observeServiceInvariant( serviceName: string, validate: (value: unknown) => string | undefined, ): void { - const inspect = (value: unknown): void => { - if (value === undefined) return - const message = validate(value) - if (message !== undefined) fail(message) - } + const observation: ServiceObservation = { fail, validate } const current: unknown = ctx.get(serviceName) - inspect(current) - ctx.on('internal/service', (name, value: unknown) => { - if (name === serviceName) inspect(value) - }, { global: true }) + inspectServiceObservation(observation, current) + const hub = serviceObservationHub(ctx) + ctx.effect( + () => addServiceObservation(hub, serviceName, observation), + `invariants.observeService(${JSON.stringify(serviceName)})`, + ) } /** Structural runtime surface required from a Cordis service implementation. */ @@ -297,7 +424,7 @@ export class InvariantService extends Service { * even when filtering disables its checks. Enabled installers run in a child * fiber; failure disposes that fiber and releases the reservation. * @param packageName - full npm package name that owns the contribution. - * @param installer - synchronous listener installer for the child context. + * @param installer - listener or startup-check installer for the child context. * @returns an effect-scoped disposer for the registration. */ register(packageName: string, installer: InvariantInstaller): () => void { @@ -324,11 +451,11 @@ export class InvariantService extends Service { } } - const installInvariant = (childCtx: Context) => { + const installInvariant = (childCtx: Context) => ( installer(childCtx, (message): never => { throw new InvariantError(packageName, message) }) - } + ) const child = ctx.plugin(installer.inject === undefined ? installInvariant : Object.assign(installInvariant, { inject: installer.inject })) diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index 44f8d61139..76fda543ad 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -273,6 +273,25 @@ describe('InvariantService lifecycle', () => { expect(retry).toHaveBeenCalledOnce() }) + it('joins asynchronous checks and rolls back their effects on failure', async () => { + const { ctx } = await setup() + const leaked = vi.fn() + const failed = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-async-probe', async (child, fail) => { + child.on('invariants-test/ping', leaked, { global: true }) + await Promise.resolve() + fail('asynchronous check failed') + })) + await expect(Promise.resolve(failed)).rejects.toThrow(/asynchronous check failed/) + ctx.emit('invariants-test/ping') + expect(leaked).not.toHaveBeenCalled() + + const retry = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-async-probe', async () => { + await Promise.resolve() + })) + await retry + await retry() + }) + it('releases a synchronous reservation if the service fiber is already inactive', async () => { const { ctx, fiber } = await setup() const service = ctx.invariants @@ -282,11 +301,15 @@ describe('InvariantService lifecycle', () => { }) describe('package-owned invariant helpers', () => { + interface InvariantDisposer { + (): void | Promise + } + async function registerInstaller( ctx: Context, packageName: string, installer: InvariantInstaller, - ): Promise<() => void> { + ): Promise { const registration = runtimeRegistration(ctx.invariants.register(packageName, installer)) const dispose = await Promise.resolve(registration) return dispose @@ -376,6 +399,38 @@ describe('package-owned invariant helpers', () => { await ctx.plugin(plugin) }) + it('multiplexes same-runtime plugin checks through one root listener pair and disposes each owner', async () => { + const { ctx } = await setup() + const firstValidation = vi.fn(() => undefined) + const secondValidation = vi.fn(() => undefined) + const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-first', (child, fail) => { + observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: firstValidation }) + }) + const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-second', (child, fail) => { + observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: secondValidation }) + }) + const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) + expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/plugin")')).toHaveLength(1) + expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/status")')).toHaveLength(1) + + const plugin = effectPlugin({ name: 'shared-plugin-probe' }) + const firstFiber = await ctx.plugin(plugin) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledOnce() + + await first() + await firstFiber.dispose() + const secondFiber = await ctx.plugin(plugin) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledTimes(2) + + await second() + await secondFiber.dispose() + await ctx.plugin(plugin) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledTimes(2) + }) + it('rejects a contract that does not identify a plugin', async () => { const { ctx } = await setup() const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-plugin', (child, fail) => { @@ -449,12 +504,46 @@ describe('package-owned invariant helpers', () => { .rejects.toThrow(/wrong watched service/) }) + it('multiplexes same-name service checks through one root listener and disposes each owner', async () => { + const { ctx } = await setup() + const firstValidation = vi.fn(() => undefined) + const secondValidation = vi.fn(() => undefined) + const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-first', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', firstValidation) + }) + const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-second', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', secondValidation) + }) + const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) + expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/service")')).toHaveLength(1) + + const firstFiber = await ctx.plugin(WatchedInvariantProbeService) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledOnce() + + await first() + const firstCallsAfterDisposal = firstValidation.mock.calls.length + const secondCallsBeforeRemount = secondValidation.mock.calls.length + await firstFiber.dispose() + const secondFiber = await ctx.plugin(WatchedInvariantProbeService) + expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterDisposal) + expect(secondValidation.mock.calls.length).toBeGreaterThan(secondCallsBeforeRemount) + + await second() + const firstCallsAfterBothDisposals = firstValidation.mock.calls.length + const secondCallsAfterBothDisposals = secondValidation.mock.calls.length + await secondFiber.dispose() + await ctx.plugin(WatchedInvariantProbeService) + expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterBothDisposals) + expect(secondValidation).toHaveBeenCalledTimes(secondCallsAfterBothDisposals) + }) + it('reports synchronous package assertions through the bound failure reporter', async () => { const { ctx } = await setup() const valid = await registerInstaller(ctx, '@deepseek-ai/dsh-valid-assertion', (_child, fail) => { assertInvariant(fail, true, 'must stay true') }) - valid() + await valid() const invalid = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-assertion', (_child, fail) => { assertInvariant(fail, false, 'must stay true') diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 826d33dd30..6fbb41a123 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -12,23 +12,20 @@ export const name = 'loader-smoke-invariant' export const inject = ['invariants'] /** Assert default source mode and plain-Node built-artifact launch resolution. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') - assertInvariant(fail, resolveExampleMode('') === 'src', - 'an empty example-mode selection must preserve source-mode development') - const launch = resolveExampleLaunch({ - srcBin: '/workspace/probe/src/bin.ts', - mode: 'lib', - }) - assertInvariant(fail, - launch.command === process.execPath - && launch.args.length === 1 - && launch.args[0] === '/workspace/probe/lib/bin.js' - && launch.env.TSX_TSCONFIG_PATH === undefined, - 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') - return () => {} - }, 'loader-smoke: validate source and built launch resolution') +const install: InvariantInstaller = async (_ctx, fail) => { + const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') + assertInvariant(fail, resolveExampleMode('') === 'src', + 'an empty example-mode selection must preserve source-mode development') + const launch = resolveExampleLaunch({ + srcBin: '/workspace/probe/src/bin.ts', + mode: 'lib', + }) + assertInvariant(fail, + launch.command === process.execPath + && launch.args.length === 1 + && launch.args[0] === '/workspace/probe/lib/bin.js' + && launch.env.TSX_TSCONFIG_PATH === undefined, + 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') } /** diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts index 30e2cfea69..8591c60dd1 100644 --- a/packages/ui/app-boot/src/invariant.ts +++ b/packages/ui/app-boot/src/invariant.ts @@ -13,18 +13,15 @@ export const name = 'app-boot-invariant' export const inject = ['invariants'] /** Assert ordinary and replay config-path selection. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { resolveConfigPath } = await import('./config-path.ts') - const cwd = '/tmp/dsh-app-boot-invariant' - const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) - const replay = resolveConfigPath('cordis.yml', 'replay', cwd) - assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), - 'ordinary app boot must retain the requested config basename') - assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), - 'snapshot replay must select cordis.snapshot.yml in the requested config directory') - return () => {} - }, 'app-boot: validate ordinary and replay config selection') +const install: InvariantInstaller = async (_ctx, fail) => { + const { resolveConfigPath } = await import('./config-path.ts') + const cwd = '/tmp/dsh-app-boot-invariant' + const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) + const replay = resolveConfigPath('cordis.yml', 'replay', cwd) + assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), + 'ordinary app boot must retain the requested config basename') + assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), + 'snapshot replay must select cordis.snapshot.yml in the requested config directory') } /** diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index 5ac98489cb..d632921d35 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -12,13 +12,10 @@ export const name = 'brand-invariant' export const inject = ['invariants'] /** Assert that the nominal-type primitive remains erased at runtime. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const brandRuntime = await import('./index.ts') - assertInvariant(fail, Object.keys(brandRuntime).length === 0, - 'the branded-id primitive must remain type-only with no runtime exports') - return () => {} - }, 'brand: validate type-only runtime erasure') +const install: InvariantInstaller = async (_ctx, fail) => { + const brandRuntime = await import('./index.ts') + assertInvariant(fail, Object.keys(brandRuntime).length === 0, + 'the branded-id primitive must remain type-only with no runtime exports') } /** diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts index 651d877e00..fc7c9c3129 100644 --- a/packages/util/home/src/invariant.ts +++ b/packages/util/home/src/invariant.ts @@ -13,17 +13,14 @@ export const name = 'home-invariant' export const inject = ['invariants'] /** Assert the canonical environment key and configured-path precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') - const environmentKey: string = DSH_HOME_ENV - assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), - 'the canonical Harness home environment key must remain DSH_HOME') - const configured = 'relative-invariant-home' - assertInvariant(fail, resolveDshHome(configured) === resolve(configured), - 'an explicitly configured Harness home must normalize to an absolute path') - return () => {} - }, 'home: validate canonical DSH home resolution') +const install: InvariantInstaller = async (_ctx, fail) => { + const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') + const environmentKey: string = DSH_HOME_ENV + assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), + 'the canonical Harness home environment key must remain DSH_HOME') + const configured = 'relative-invariant-home' + assertInvariant(fail, resolveDshHome(configured) === resolve(configured), + 'an explicitly configured Harness home must normalize to an absolute path') } /** diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index ce0ddb653c..20a3962ff4 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -14,17 +14,14 @@ export const name = 'paths-invariant' export const inject = ['invariants'] /** Assert tilde expansion and explicit-over-environment home precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') - assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), - 'supported tilde prefixes must expand against the operating-system home') - const configured = 'relative-invariant-home' - const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) - assertInvariant(fail, resolved === resolve(configured), - 'an explicit DSH home must override the environment and normalize to an absolute path') - return () => {} - }, 'paths: validate DSH home resolution') +const install: InvariantInstaller = async (_ctx, fail) => { + const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') + assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), + 'supported tilde prefixes must expand against the operating-system home') + const configured = 'relative-invariant-home' + const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) + assertInvariant(fail, resolved === resolve(configured), + 'an explicit DSH home must override the environment and normalize to an absolute path') } /** diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index 380e4e3d4b..fa8ab7d36a 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -12,24 +12,21 @@ export const name = 'retention-invariant' export const inject = ['invariants'] /** Assert exact head-retention accounting after the budget is exceeded. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { ItemRetainer } = await import('./index.ts') - const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) - retainer.push('first') - retainer.push('second') - retainer.push('third') - const result = retainer.finish() - assertInvariant(fail, - result.items.join(',') === 'first,second' - && result.seen === 3 - && result.kept === 2 - && result.truncated - && result.omitted.kind === 'exact' - && result.omitted.count === 1, - 'head retention must keep the prefix and report exact seen, kept, and omitted counts') - return () => {} - }, 'retention: validate exact head accounting') +const install: InvariantInstaller = async (_ctx, fail) => { + const { ItemRetainer } = await import('./index.ts') + const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) + retainer.push('first') + retainer.push('second') + retainer.push('third') + const result = retainer.finish() + assertInvariant(fail, + result.items.join(',') === 'first,second' + && result.seen === 3 + && result.kept === 2 + && result.truncated + && result.omitted.kind === 'exact' + && result.omitted.count === 1, + 'head retention must keep the prefix and report exact seen, kept, and omitted counts') } /** diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index 8302380eaa..2d9d22f5d6 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -12,19 +12,16 @@ export const name = 'timeout-invariant' export const inject = ['invariants'] /** Assert default-before-cap arithmetic and capability-code classification. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') - assertInvariant(fail, - clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, - 'timeout resolution must apply the default before capping and preserve smaller requests') - const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) - assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, - 'timeout classification must recover a matching capability-owned reason') - assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, - 'timeout classification must reject a reason owned by another capability') - return () => {} - }, 'timeout: validate resolution and reason classification') +const install: InvariantInstaller = async (_ctx, fail) => { + const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') + assertInvariant(fail, + clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, + 'timeout resolution must apply the default before capping and preserve smaller requests') + const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) + assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, + 'timeout classification must recover a matching capability-owned reason') + assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, + 'timeout classification must reject a reason owned by another capability') } /** diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 77150abb74..5c3b9120bc 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' -import { MANUAL_INVARIANT_TESTS, testInvariantCompanions } from './test-invariants.ts' +import { + MANUAL_INVARIANT_TESTS, + testInvariantCompanionPaths, + testInvariantCompanions, +} from './test-invariants.ts' declare module 'cordis' { interface Context { @@ -17,7 +21,7 @@ class TestInvariantProbe extends Service { } describe('global test invariant host', () => { - it('loads every companion and reserves every package name with enabled checks', async () => { + it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() await ctx.plugin(TestInvariantProbe) @@ -39,6 +43,14 @@ describe('global test invariant host', () => { expect(unreserved).toEqual([]) }) + it('mounts the owning package companion while leaving non-package roots service-only', () => { + expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts')) + .toEqual(['../packages/core/tools/src/invariant.ts']) + expect(testInvariantCompanionPaths('/repo/examples/echo-agent/tests/echo.spec.ts')).toEqual([]) + expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts')) + .toEqual(Object.keys(testInvariantCompanions).sort()) + }) + it('executes each companion registration with its owning package name', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 584db3d5d7..b91fdda462 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -1,7 +1,8 @@ /** * Vitest-wide invariant host. Ordinary Cordis roots receive the invariant - * service with global enablement and every package companion before their first - * plugin starts. Focused invariant tests own their service topology explicitly. + * service with global enablement plus the current test package's companion. + * One topology test mounts every companion; focused invariant tests own their + * service topology explicitly. */ import { expect } from 'vitest' @@ -40,6 +41,7 @@ export const MANUAL_INVARIANT_TESTS = [ interface InvariantHost { readonly fibers: readonly PluginFiber[] readonly byCallback: ReadonlyMap + readonly ready: Promise } type PluginFiber = ReturnType @@ -55,13 +57,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge const host = hosts.get(root) ?? startInvariantHost(root) const callback = this.resolve(plugin) const existing = callback === undefined ? undefined : host.byCallback.get(callback) - if (existing !== undefined) return existing + if (existing !== undefined) { + return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing + } const fiber = originalPlugin.call(this, plugin, config, getOuterStack) // A root-level await is the test's composition boundary. Nested plugin // fibers must not await their own companion parent through the global host. if (this.ctx !== root) return fiber - return joinInvariantStartup(fiber, host.fibers) + return joinInvariantStartup(fiber, host.ready) } function usesManualInvariantTree(): boolean { @@ -69,6 +73,30 @@ function usesManualInvariantTree(): boolean { return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path)) } +const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const + +/** + * Select the package companions that an ordinary test root must register. + * Package tests receive their owner's checks; the dedicated topology test + * receives every owner so coverage and exhaustive runtime registration remain + * independently enforced. + * @param testPath - absolute or repo-relative normalized Vitest file path. + * @returns sorted `import.meta.glob` keys for companions to mount. + */ +export function testInvariantCompanionPaths(testPath: string): string[] { + const normalized = testPath.replaceAll('\\', '/') + const allPaths = Object.keys(testInvariantCompanions).sort() + if (ALL_COMPANION_TESTS.some(path => normalized.endsWith(path))) return allPaths + + const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//) + if (owner === null) return [] + const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts` + if (testInvariantCompanions[companionPath] === undefined) { + throw new Error(`test invariants: package test has no companion at ${companionPath}`) + } + return [companionPath] +} + function startInvariantHost(root: Context): InvariantHost { const fibers: PluginFiber[] = [] const byCallback = new Map() @@ -81,21 +109,35 @@ function startInvariantHost(root: Context): InvariantHost { } mount(InvariantService, { enabled: true }) - for (const [path, companion] of Object.entries(testInvariantCompanions).sort(([left], [right]) => left.localeCompare(right))) { + const testPath = expect.getState().testPath ?? '' + const companionPaths = testInvariantCompanionPaths(testPath) + for (const path of companionPaths) { + const companion = testInvariantCompanions[path] + if (companion === undefined) { + throw new Error(`test invariants: selected companion vanished at ${path}`) + } if (!companion.inject.includes('invariants')) { throw new Error(`test invariants: ${path} must inject the invariant service`) } mount(companion) } - const host = { fibers, byCallback } + const [serviceFiber, ...companionFibers] = fibers + if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted') + // A companion is initially PENDING on the invariant service, and Cordis + // Fiber.await() only joins work already in flight. Wait for the service to + // activate its dependants before joining their startup and failures. + const ready = serviceFiber.await() + .then(() => Promise.all(companionFibers.map(fiber => fiber.await()))) + .then(() => undefined) + const host = { fibers, byCallback, ready } hosts.set(root, host) return host } -function joinInvariantStartup(fiber: PluginFiber, invariantFibers: readonly PluginFiber[]): PluginFiber { +function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { const readiness = fiber.await().then(async (loaded) => { - await Promise.all(invariantFibers.map(invariant => invariant.await())) + await invariantReady return loaded }) const joined = Object.create(fiber) as PluginFiber From 512ef2a18e73f930077f91800d0b1e6beaeedd3b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:00:40 +0800 Subject: [PATCH 08/30] docs(invariants): refresh installer API catalogs --- docs/cordis-catalog/services.md | 4 ++-- packages/cordis/tool-cordis/src/api-catalog.ts | 4 ++-- website/zh-CN/api/harness/invariants.md | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7da704df75..79e540e7bd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -489,13 +489,13 @@ Package-owned invariant registry with global and regex-based selection. * even when filtering disables its checks. Enabled installers run in a child * fiber; failure disposes that fiber and releases the reservation. * @param packageName - full npm package name that owns the contribution. - * @param installer - synchronous listener installer for the child context. + * @param installer - listener or startup-check installer for the child context. * @returns an effect-scoped disposer for the registration. */ register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:261`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:388`](../../packages/support/invariants/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 40b8f38471..bceeed2ad9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -256,7 +256,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'register(packageName: string, installer: InvariantInstaller): () => void', - jsDoc: '/**\n * Register one package\'s invariant installer. The package name is reserved\n * even when filtering disables its checks. Enabled installers run in a child\n * fiber; failure disposes that fiber and releases the reservation.\n * @param packageName - full npm package name that owns the contribution.\n * @param installer - synchronous listener installer for the child context.\n * @returns an effect-scoped disposer for the registration.\n */', + jsDoc: '/**\n * Register one package\'s invariant installer. The package name is reserved\n * even when filtering disables its checks. Enabled installers run in a child\n * fiber; failure disposes that fiber and releases the reservation.\n * @param packageName - full npm package name that owns the contribution.\n * @param installer - listener or startup-check installer for the child context.\n * @returns an effect-scoped disposer for the registration.\n */', }, ], }, @@ -1192,7 +1192,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvariantInstaller', - declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void;\n readonly inject?: Inject;\n}', + declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise;\n readonly inject?: Inject;\n}', }, { name: 'JsonValue', diff --git a/website/zh-CN/api/harness/invariants.md b/website/zh-CN/api/harness/invariants.md index 61589489f7..e2f582a566 100644 --- a/website/zh-CN/api/harness/invariants.md +++ b/website/zh-CN/api/harness/invariants.md @@ -6,7 +6,7 @@ Package-owned invariant registry with global and regex-based selection. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L261) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L388) ### ctx.invariants.register(packageName, installer) @@ -16,7 +16,7 @@ Package-owned invariant registry with global and regex-based selection. * even when filtering disables its checks. Enabled installers run in a child * fiber; failure disposes that fiber and releases the reservation. * @param packageName - full npm package name that owns the contribution. - * @param installer - synchronous listener installer for the child context. + * @param installer - listener or startup-check installer for the child context. * @returns an effect-scoped disposer for the registration. */ register(packageName: string, installer: InvariantInstaller): () => void @@ -25,8 +25,8 @@ register(packageName: string, installer: InvariantInstaller): () => void Register one package's invariant installer. The package name is reserved even when filtering disables its checks. Enabled installers run in a child fiber; failure disposes that fiber and releases the reservation. - `packageName` — full npm package name that owns the contribution. -- `installer` — synchronous listener installer for the child context. +- `installer` — listener or startup-check installer for the child context. **Returns** an effect-scoped disposer for the registration. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L303) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/support/invariants/src/index.ts#L430) From cfa180c127715d52d5deb53b56c14725dd7dd73d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 15:34:00 +0800 Subject: [PATCH 09/30] Resolve compaction policy per routed model --- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +- ...mpaction-pressure-and-overflow-recovery.md | 4 +- ...ction-pressure-and-overflow-recovery.zh.md | 4 +- ...07-15-replay-token-meter-service.i18n.yaml | 4 +- .../2026-07-15-replay-token-meter-service.md | 12 +- ...026-07-15-replay-token-meter-service.zh.md | 12 +- ...el-context-and-compaction-policy.i18n.yaml | 6 + ...ted-model-context-and-compaction-policy.md | 57 ++++ ...-model-context-and-compaction-policy.zh.md | 57 ++++ .../2026-06-18-compaction-capability-seam.md | 4 +- docs/config-catalog.md | 43 ++- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 16 +- docs/core-data-structures/core.md | 10 + docs/core-data-structures/llm-streaming.md | 13 +- docs/event-producer-consumer.md | 2 +- examples/acp-agent/cordis.yml | 14 +- examples/headless-agent/composition.md | 3 + examples/headless-agent/cordis.yml | 9 +- examples/jsonrpc-agent/cordis.yml | 7 +- examples/repl-agent/cordis.yml | 4 +- examples/repl-agent/tests/compaction.e2e.ts | 4 +- examples/repl-agent/tests/harness.ts | 14 +- packages/compact/compact-basic/README.md | 29 +- packages/compact/compact-basic/src/config.ts | 309 ++++++++++++++---- packages/compact/compact-basic/src/index.ts | 129 ++++++-- .../compact/compact-basic/src/summarizer.ts | 9 +- packages/compact/compact-basic/src/types.ts | 57 +++- .../compact-basic/tests/compact-basic.spec.ts | 274 ++++++++++++++-- .../tests/compact-loop-repro.spec.ts | 12 +- .../tests/loader-composition.spec.ts | 25 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 + packages/llm/README.md | 2 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/src/adapter.ts | 18 +- packages/llm/llm-deepseek/src/index.ts | 12 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 30 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 18 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 7 + packages/llm/llm/README.md | 5 +- packages/llm/llm/src/index.ts | 46 ++- packages/llm/llm/src/types.ts | 6 + packages/llm/llm/tests/service.spec.ts | 44 ++- packages/llm/token-meter/README.md | 14 +- packages/llm/token-meter/src/index.ts | 35 +- packages/llm/token-meter/src/types.ts | 7 +- .../llm/token-meter/tests/token-meter.spec.ts | 34 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 1 + website/zh-CN/api/harness/events.md | 2 +- website/zh-CN/api/harness/llm.md | 33 +- website/zh-CN/api/harness/token-meter.md | 17 +- website/zh-CN/guide/config.md | 32 +- 54 files changed, 1210 insertions(+), 319 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md create mode 100644 .agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index a6344d276f..ee9ecbf681 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: f1a1868cd00007fb24efb21779dcc94c098b54e2 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: a4993de2830301610bb2a9b0d28e8bbdf0ed9c46 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: e9e1215734aaf4765ff572476132d7715e3a55ec +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 5c5082345d0fabf7854d670de0fc28dfb3de5e7b diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index f1a1868cd0..e9e1215734 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -32,9 +32,9 @@ If cancellation lands after assistant tool calls are durable but before all call `CompactService.compactIfNeeded(agent, trigger, signal)` accepts `trigger: 'pressure' | 'context-overflow'`. The interface gains no estimation methods or token types; `ctx.tokenMeter` remains the reusable accounting owner. -For `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. +For `pressure`, compact-basic resolves the durable provider/model target's adapter-owned capacity and exact-target policy, then applies the resulting threshold and retained-tail budgets to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. Common defaults remain threshold ratio `0.8`, retained-history ratio `0.16`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`; optional `modelPolicies` entries override them for an exact provider/model pair. -For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry. +For canonical overflow, compact-basic requires no capacity metadata and bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry. `maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. Cancellation or disposal remains authoritative even if recovery work completes concurrently. diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index a4993de283..5c5082345d 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -32,9 +32,9 @@ Status: implemented `CompactService.compactIfNeeded(agent, trigger, signal)` 接收 `trigger: 'pressure' | 'context-overflow'`。接口不增加估算方法或 token 类型;`ctx.tokenMeter` 继续作为可复用的核算所有者。 -对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。 +对于 `pressure`,compact-basic 先解析持久提供方/模型目标的适配器所属容量与精确目标策略,再把得到的阈值与保留尾部预算应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史比例 `0.16`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`;可选 `modelPolicies` 项可以按精确提供方/模型组合覆盖这些值。 -对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。 +对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。 `maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index d49aedb545..c560903ca7 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-replay-token-meter-service.md: 9bbc177f456e006179c466f8c245e4599db3dd5a -2026-07-15-replay-token-meter-service.zh.md: 4437626c8651a80537d45197a93733271a592173 +2026-07-15-replay-token-meter-service.md: 4bedbb0cb9fa383108a688dbbb32546c7f39bd20 +2026-07-15-replay-token-meter-service.zh.md: ccf1b014cd86d16ef498b4209818e78be562e66f diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md index 9bbc177f45..4bedbb0cb9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -6,7 +6,7 @@ English | [中文](2026-07-15-replay-token-meter-service.zh.md) ## Problem -Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how much of the configured context window does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting. +Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how many tokens does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting. Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch providers and models, old logs can lack chunk provenance, and usage fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines the latest exact anchor with conservative heuristic repricing and exposes the log revision consumed by each result. @@ -14,9 +14,9 @@ Provider usage is not a complete answer. It describes one successful call under ### One concrete LLM-family service -`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `contextWindow`, `measure(session, requestHeader?)`, and `estimateMessage(message)`; consumers call the singleton service directly. +`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `measure(session, requestHeader?)` and `estimateMessage(message)`; consumers call the singleton service directly. -The service has one `contextWindow`, defaulting to 128,000 tokens and configurable as a positive integer. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, density settings, tokenizer backends, or language-specific strategies. +The service has no configuration. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, capacity settings, density settings, tokenizer backends, or language-specific strategies. Exact provider/model capacity is a separate adapter-owned query, as specified by the [routed model context and compaction policy Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md). ### Per-session replay folds @@ -34,7 +34,7 @@ Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reas Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization; any intervening durable append changes `logRevision` and prevents replacement. -Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. +Compact policy has service-wide defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level fields apply to every routed target; exact provider/model entries in `modelPolicies` partially override them. Pressure scales ratios against capacity resolved from the owning adapter, and `retainTokens` may replace `retainRatio`; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope produced under the provider/model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement. @@ -46,14 +46,14 @@ Unit tests cover fixed estimation, envelope invalidation and anchor replacement, - **Keep estimation inside `CompactService`** — rejected because measurement has consumers and replay semantics independent of compaction; it would also force every compactor to expose the same unrelated API. - **Split a token-meter interface from a heuristic backend immediately** — rejected because only one implementation exists. One concrete service preserves the future seam without speculative packages or configuration. -- **Keep model-keyed windows and density profiles** — rejected because the deployment currently has one context policy and one estimator. Model registries, unknown-model failures, and configurable density add branches without a second behavior to select. +- **Put model-keyed windows and density profiles in the meter** — rejected because replay estimation does not own model routing or capacity facts. The route-owning adapter exposes capacity, while compact-basic owns the consumer-specific threshold and retention policy. - **Keep separate scalar and surface measurements** — rejected because callers would need two reads and revision matching for one decision. A scalar-only read could avoid cloning nodes below threshold, but the split API introduces a caller-side race window; the unified snapshot accepts O(surface) cloning in exchange for coherence. - **Treat provider usage as portable between envelopes** — rejected because model, tools, prefixes, and call config are request facts. Mismatch reprices the whole current request. ## Consequences - Token pressure has one replay-aware owner that compaction and future plugins can share. -- The default makes the bundled composition usable with two zero-config plugin entries; deployments override one context capacity when needed. +- The default makes the meter a zero-config composition entry; deployments configure capacity on each route-owning adapter and optional policy overrides on compact-basic. - Fixed heuristic pricing remains an estimate of provider behavior and is not an exact tokenizer or request serializer. - Every measurement clones the current positional surface and therefore costs O(surface), including pressure checks that finish below threshold. - Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure. diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 4437626c86..ccf1b014cd 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求占用了已配置上下文窗口的多少容量?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。 +上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求消耗了多少 token?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。 提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换提供方与模型,旧日志可能缺少分片来源,usage 字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把最新精确锚点与保守的启发式重新定价结合起来,并公开每个结果已经消费的日志修订号。 @@ -14,9 +14,9 @@ Status: implemented ### 一个具体的 LLM 家族服务 -`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `contextWindow`、`measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 +`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 -服务只有一个 `contextWindow`,默认值为 128,000 token,并允许配置为正整数。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、密度设置、分词器后端或语言专用策略。 +服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。精确提供方/模型容量由独立的适配器查询拥有,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md)。 ### 逐会话回放折叠 @@ -34,7 +34,7 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务先追加持久 `compact/start` 锁,再执行一次计量,并在异步摘要完成后再次计量;期间任何持久追加都会改变 `logRevision`,从而阻止替换。 -压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 +压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层字段适用于每个路由目标;`modelPolicies` 中的精确提供方/模型项可以部分覆盖这些字段。压力检查根据所属适配器解析的容量缩放比例,`retainTokens` 可以替代 `retainRatio`;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 自动压力检查运行在 `agent/post-step`,并计量 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。 @@ -46,14 +46,14 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket - **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。 - **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的包与配置。 -- **保留模型键控的窗口与密度 profile**——不予采纳,因为当前部署只有一种上下文策略与一个估算器。模型注册表、未知模型错误和可配置密度只增加分支,却没有第二种行为可供选择。 +- **把模型键控窗口与密度 profile 放进 meter**——不予采纳,因为回放估算不拥有模型路由或容量事实。路由所属适配器公开容量,compact-basic 则拥有消费方专用的阈值与保留策略。 - **保留独立的标量与表层计量**——不予采纳,因为消费方必须为一次决策执行两次读取并匹配修订号。仅读取标量可以避免在低于阈值时复制节点,但拆分 API 会在消费方引入竞态窗口;统一快照接受 O(surface) 复制成本,以换取结果一致性。 - **在不同信封之间移用提供方 usage**——不予采纳,因为模型、工具、前缀与调用配置都是请求事实。不匹配时会重新定价完整当前请求。 ## 后果 - Token 压力拥有一个可供压缩与未来插件共享的回放感知所有者。 -- 默认值让内置组合只需两个零配置插件条目即可使用;部署需要时只覆盖一个上下文容量。 +- 默认值让 meter 成为零配置组合项;部署在各个路由所属适配器上配置容量,并在 compact-basic 上配置可选策略覆盖。 - 固定启发式定价仍然只是提供方行为的估计,并不是精确分词器或请求序列化器。 - 每次计量都会复制当前的位置表层,因此成本为 O(surface),低于阈值即可结束的压力检查也不例外。 - 遇到畸形持久边界时,计量会明确失败。这会把损坏的回放转化为具名集成错误,而不是让压力静默漂移。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml new file mode 100644 index 0000000000..c9290db5ea --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-routed-model-context-and-compaction-policy.md: f0b9288d3d864bfcc2964862b1ff294406daa345 +2026-07-20-routed-model-context-and-compaction-policy.zh.md: cda740a5671a3ef8a5bb415e5cc45ca8397c1c59 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md new file mode 100644 index 0000000000..f0b9288d3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md @@ -0,0 +1,57 @@ +# Agent Note: Routed model context and compaction policy + +Status: implemented + +English | [中文](2026-07-20-routed-model-context-and-compaction-policy.zh.md) + +## Problem + +Compaction cannot safely apply one global context window when a process routes requests to models with different capacities. The same model id can also exist under multiple providers, and an adapter may accept dynamic ids absent from its advisory catalog. A wrong capacity either compacts too late and triggers avoidable overflow or compacts too early and discards useful context. + +Neither obvious configuration owner is sufficient. Compact-basic is optional and does not know which models an adapter accepts. LLM adapters own model routing but must not depend on an optional compaction plugin or absorb consumer-specific threshold, retention, summarizer, and retry policy. The design needs an authoritative capacity fact and optional per-target compaction policy without creating a second model registry. + +## Decision + +### Adapters own exact-route capacity + +`LlmAdapter.resolveModelContext(provider, model)` optionally returns `LlmModelContext` for one exact route. `LlmService.resolveModelContext()` selects the registered route owner, validates a positive integer `contextWindow`, and returns a detached value. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and `undefined` means only that the adapter cannot describe capacity. + +The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model. Its two default model entries publish 128,000 tokens; an explicit entry without capacity and an unlisted pass-through id return `undefined`. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model. + +### Token measurement remains model-agnostic + +`dsh-token-meter` has no configuration and no model profiles. It owns one fixed replay fold and returns absolute estimated token pressure plus positional surface prices. Removing global capacity keeps measurement reusable when compact-basic is absent and prevents replay accounting from becoming another model registry. + +### Compact-basic resolves a target spec + +Compact-basic owns consumer policy. Top-level fields define defaults; `modelPolicies` contains partial overrides keyed by the exact `{ provider, model }` pair. Duplicate targets and unknown or invalid fields fail plugin load. `thresholdRatio` defaults to `0.8`, and retention defaults to `retainRatio: 0.16`; callers may use an absolute `retainTokens` instead, but the two retention forms are mutually exclusive. After inheritance, a ratio retention that is not below its threshold ratio also fails plugin load because no model capacity can make that policy valid. + +For proactive pressure, compact-basic reads the latest durable request route, resolves its adapter capacity and exact-target policy, and scales ratios into a `ResolvedCompactSpec`. It performs this resolution on every check, so a provider or model switch in one session changes capacity and policy immediately. An absolute retained budget that is not below the scaled threshold fails when the target capacity first makes that comparison possible. + +The same exact-target override can select summarization provider/model, summarization output cap, convergence retries, and overflow retry cap. These are compaction concerns and never enter the adapter seam. + +### Target-specific pressure failures preserve optional composition + +An adapter that lacks capacity metadata remains a valid LLM route. Manual proactive pressure fails with a target-specific configuration error; the automatic listener warns once per exact route and continues with full history. The same per-route suppression applies when resolved capacity exposes an invalid absolute retention budget, while unrelated operational failures remain independently visible. Canonical provider-confirmed overflow does not need capacity metadata: it bypasses the proactive threshold and normal retention budget, attempts one maximal balanced reduction, and preserves the original provider error unless replacement proves progress. + +## Testing + +Service tests cover detached context metadata, invalid adapter output, catalog independence, and default absence. Adapter tests cover DeepSeek configured/default/unlisted behavior and pi-ai exact descriptor resolution. Compact tests cover ratio scaling, exact provider/model overrides, load-time rejection of invalid merged ratios, runtime absolute-budget validation, same-model-id provider switches, target-specific warning suppression, and capacity-independent overflow recovery. Loader fixtures reject the removed token-meter capacity setting, and examples configure capacity on adapters. + +## Alternatives considered + +- **Put capacity and all policies in compact-basic** — rejected because compact-basic would duplicate adapter model knowledge, dynamic unlisted models would require parallel registration, and capacity would disappear when compaction is not installed. +- **Put compaction policy in each LLM adapter** — rejected because adapters must remain independent of optional consumers, while summarization and retry policy are not provider facts. +- **Make `listModels()` authoritative** — rejected because discovery is advisory and some adapters intentionally accept dynamic ids. Correctness metadata must not turn selector membership into a routing whitelist. +- **Add per-model folds to token-meter** — rejected because the replay algorithm is shared; only the capacity and consumer policy change. Multiple folds would duplicate state without improving estimation. +- **Create a standalone model-context registry** — rejected because the adapter already owns authoritative route resolution. A second registry would introduce lifecycle ordering, duplicate-key, and drift problems without an independent backend. + +## Consequences + +- Capacity has one authoritative owner at the provider seam, while compaction policy stays in the optional consuming plugin. +- The same compact-basic instance safely handles different windows, provider switches, and identical model ids under different providers without consulting discovery metadata. +- LLM-only and meter-only compositions remain valid; loading compact-basic adds no reverse dependency from adapters. +- Deployments using explicit DeepSeek model lists must provide `contextWindow` for proactive pressure on those entries. Missing metadata is visible instead of silently applying a wrong global fallback. +- Ratio defaults scale naturally across models, while exact-target absolute retention remains available for deployment-specific behavior. + +This note supersedes the global-capacity and no-model-policy parts of the [replay token meter service Agent Note](2026-07-15-replay-token-meter-service.md). Its single-fold measurement decision remains unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md new file mode 100644 index 0000000000..cda740a567 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md @@ -0,0 +1,57 @@ +# Agent Note: 路由模型上下文与压缩策略 + +Status: implemented + +[English](2026-07-20-routed-model-context-and-compaction-policy.md) | 中文 + +## 问题 + +当一个进程把请求路由到不同容量的模型时,压缩不能安全地应用同一个全局上下文窗口。相同模型 id 也可能存在于多个提供方下,适配器还可能接受不在建议目录中的动态 id。错误容量要么让压缩触发过晚并造成原本可避免的溢出,要么让压缩触发过早并丢弃有用上下文。 + +两个直观的配置归属方都无法独立解决问题。Compact-basic 是可选插件,不知道适配器接受哪些模型。LLM 适配器拥有模型路由,但不能依赖可选压缩插件,也不应吸收消费方专用的阈值、保留、摘要器与重试策略。该设计既需要权威容量事实和可选的逐目标压缩策略,又不能建立第二套模型注册表。 + +## 决策 + +### 适配器拥有精确路由容量 + +`LlmAdapter.resolveModelContext(provider, model)` 可以为一条精确路由返回 `LlmModelContext`。`LlmService.resolveModelContext()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离值。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而 `undefined` 只表示适配器无法描述容量。 + +手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`。两个默认模型项都公开 128,000 token;未提供容量的显式模型项与未列出的透传 id 返回 `undefined`。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。 + +### Token 计量保持模型无关 + +`dsh-token-meter` 没有配置,也没有模型 profile。它拥有一个固定回放折叠,并返回绝对估算 token 压力与逐位置表层价格。移除全局容量后,未加载 compact-basic 时仍可复用计量,同时避免让回放核算变成另一套模型注册表。 + +### Compact-basic 解析目标规格 + +Compact-basic 拥有消费方策略。顶层字段定义默认值;`modelPolicies` 包含以精确 `{ provider, model }` 组合为键的部分覆盖。重复目标、未知字段或无效字段都会让插件加载失败。`thresholdRatio` 默认为 `0.8`,保留策略默认为 `retainRatio: 0.16`;调用方也可以改用绝对 `retainTokens`,但两种保留形式互斥。完成继承后,如果保留比例不小于阈值比例,插件也会加载失败,因为任何模型容量都无法让该策略有效。 + +对于主动压力检查,compact-basic 读取最新持久请求路由,解析其适配器容量与精确目标策略,再把比例缩放为 `ResolvedCompactSpec`。每次检查都会重新解析,因此同一会话切换提供方或模型后,容量与策略会立即变化。若绝对保留预算不小于缩放后的阈值,系统会在目标容量首次允许比较两者时失败。 + +同一精确目标覆盖还可以选择摘要提供方/模型、摘要输出上限、收敛重试次数与溢出重试上限。这些都属于压缩问题,不会进入适配器 seam。 + +### 目标专用压力错误仍保留可选组合 + +缺少容量元数据的适配器仍是有效 LLM 路由。手动主动压力检查会返回目标专用配置错误;自动监听器按精确路由只警告一次,并继续保留完整历史。当已解析容量暴露出无效的绝对保留预算时,系统也按路由抑制重复警告;其他运行故障仍会各自对外可见。提供方已经确认的规范化溢出不需要容量元数据:它绕过主动阈值与普通保留预算,尝试一次最大的平衡缩减,并在替换无法证明进展时保留原始提供方错误。 + +## 测试 + +服务测试覆盖分离上下文元数据、无效适配器输出、目录独立性与默认缺失行为。适配器测试覆盖 DeepSeek 的配置值、默认值与未列出行为,以及 pi-ai 的精确描述符解析。压缩测试覆盖比例缩放、精确提供方/模型覆盖、加载期拒绝无效合并比例、运行时校验绝对预算、相同模型 id 的提供方切换、目标专用警告抑制与不依赖容量的溢出恢复。Loader fixture 会拒绝已经移除的 token-meter 容量设置,示例则在适配器上配置容量。 + +## 考虑过的替代方案 + +- **把容量与所有策略都放进 compact-basic**——不予采纳,因为 compact-basic 会复制适配器的模型知识,未列出的动态模型需要并行注册,而且未安装压缩时容量也会消失。 +- **把压缩策略放进各个 LLM 适配器**——不予采纳,因为适配器必须独立于可选消费方,而摘要与重试策略也不是提供方事实。 +- **让 `listModels()` 成为权威来源**——不予采纳,因为发现能力只是建议信息,一些适配器有意接受动态 id。正确性元数据不能把选择器成员关系变成路由白名单。 +- **给 token-meter 增加逐模型折叠**——不予采纳,因为回放算法可以共享,变化的只有容量与消费方策略。多个折叠会重复状态,却不会改善估算。 +- **建立独立模型上下文注册表**——不予采纳,因为适配器已经拥有权威路由解析。第二套注册表会引入生命周期顺序、重复键与漂移问题,却没有独立后端。 + +## 后果 + +- 容量在提供方 seam 上拥有唯一权威归属方,而压缩策略留在可选消费插件中。 +- 同一个 compact-basic 实例无需查询发现元数据,就能安全处理不同窗口、提供方切换,以及不同提供方下的相同模型 id。 +- 仅 LLM 与仅 meter 的组合仍然有效;加载 compact-basic 不会让适配器产生反向依赖。 +- 使用显式 DeepSeek 模型列表的部署必须为需要主动压力检查的条目提供 `contextWindow`。系统会暴露缺失元数据,而不是静默应用错误的全局回退值。 +- 比例默认值会随模型自然缩放,同时仍可按精确目标使用绝对保留值,以满足部署专用行为。 + +本记录取代[回放式 token 计量服务 Agent Note](2026-07-15-replay-token-meter-service.md) 中的全局容量与无模型策略部分,单折叠计量决策保持不变。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index a7b06a8379..f90541f29b 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -52,7 +52,7 @@ retry → next numbered step/start ⟵ derives from the replacement surface Auto-compaction checks after **every successful** step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows within a turn. The post-step check can compact early closed tool pairs before continuation opens the next step, and provider-confirmed overflow remains the backstop when a request crosses the limit first. -`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. +`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches the resolved retained-token budget and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. @@ -64,7 +64,7 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Approximate convergence invariant -`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization provider/model overrides, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Optional top-level `thresholdRatio` and `retainTokens` override the policy for the token meter's single context window; retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. Overflow bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit. +`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, empty summarization provider/model overrides, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Optional exact provider/model policies partially override the top-level defaults; pressure scales ratios against capacity from the route-owning LLM adapter, while `retainTokens` can replace ratio retention. Retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. Overflow needs no capacity metadata and bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit. The ownership split is specified by the [routed model context and compaction policy Agent Note](../architecture/2026-07-20-routed-model-context-and-compaction-policy.md). ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 11550beefb..bf55077c64 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -287,15 +287,25 @@ Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:21`](../package Requires: `llm` · `tokenMeter` ```ts config-catalog -/** Basic compaction configuration; every common field has a deployment default. */ -export interface BasicCompactConfig { - /** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */ +/** Basic compaction configuration with an optional exact-target policy table. */ +export interface BasicCompactConfig extends CompactPolicyConfig { + /** Exact provider/model overrides; duplicate targets fail plugin load. */ + modelPolicies?: ModelCompactPolicyConfig[] + /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ + auto?: boolean +} + +/** Policy fields shared by the default policy and exact model overrides. */ +export interface CompactPolicyConfig { + /** Compact at this fraction of the model's context window. Defaults to `0.8`. */ thresholdRatio?: number - /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ + /** Recent context retained as a fraction of the model's window. Defaults to `0.16`. */ + retainRatio?: number + /** Absolute recent-context budget; mutually exclusive with `retainRatio`. */ retainTokens?: number - /** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary provider; set together with `summarizationModel`, or inherit the conversation target. */ summarizationProvider?: string - /** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary model; set together with `summarizationProvider`, or inherit the conversation target. */ summarizationModel?: string /** Provider generation cap for summarization. Defaults to `8192`. */ maxTokens?: number @@ -303,12 +313,18 @@ export interface BasicCompactConfig { compactionRetries?: number /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ maxOverflowRetries?: number - /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ - auto?: boolean +} + +/** Exact provider/model override merged over the default compaction policy. */ +export interface ModelCompactPolicyConfig extends CompactPolicyConfig { + /** Registered provider route to match. */ + provider: string + /** Exact routed model id to match within `provider`. */ + model: string } ``` -Source: [`packages/compact/compact-basic/src/types.ts:8`](../packages/compact/compact-basic/src/types.ts) +Source: [`packages/compact/compact-basic/src/types.ts:38`](../packages/compact/compact-basic/src/types.ts) ## `@deepseek-ai/dsh-fs-local` @@ -437,6 +453,8 @@ export interface DeepSeekCatalogModel { name?: string /** Optional selector detail for deployments with similar model variants. */ description?: string + /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ + contextWindow?: number } ``` @@ -1005,11 +1023,8 @@ Source: [`packages/context/time-context/src/index.ts:19`](../packages/context/ti ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog -/** Token-meter plugin configuration. */ -export interface TokenMeterConfig { - /** Service-wide context-window capacity in tokens. Defaults to `128000`. */ - contextWindow?: number -} +/** Token-meter plugin configuration; the fixed estimator has no settings. */ +export type TokenMeterConfig = object ``` Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..61e137ccb2 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -473,7 +473,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:50`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..8ff9c8db0b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -508,6 +508,16 @@ listProviders(): LlmProviderInfo[] */ async listModels(provider: string): Promise +/** + * Resolve context capacity from the adapter that owns one exact route. + * This query is independent of the advisory model catalog: an unlisted model + * may return metadata, while `undefined` never rejects later routing. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached context metadata, or `undefined` when the adapter has none. + */ +async resolveModelContext( provider: string, model: string, ): Promise + /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for @@ -523,9 +533,9 @@ async listModels(provider: string): Promise stream(options: GenerateOptions): AsyncIterable ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:97`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:118`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1106,7 +1116,7 @@ estimateMessage(message: Message): number Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) -Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1b87513e11..67be566f48 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -190,6 +190,16 @@ interface LlmModelInfo { } ``` +Correctness-sensitive model capacity is queried separately from the advisory catalog and is owned by the adapter serving the exact route. + +```ts type-equiv +/** Provider-owned context capacity for one exact provider/model route. */ +interface LlmModelContext { + /** Maximum combined request and response context in tokens. */ + contextWindow: number +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 41ce6427e6..f370edb84a 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -132,7 +132,7 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts public-api /** @@ -156,6 +156,17 @@ declare abstract class LlmAdapter { * @returns discoverable models in adapter-preferred order. */ listModels(_provider: string): Promise; + /** + * Resolve context capacity for one model accepted by this adapter. Absence + * means the adapter does not know the capacity, not that routing is invalid. + * @param _provider - one provider route owned by this adapter. + * @param _model - exact model id passed to {@link GenerateOptions.model}. + * @returns provider-owned context metadata, or `undefined` when unavailable. + */ + resolveModelContext( + _provider: string, + _model: string, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..5e56af710d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:50`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 510fac46b6..b54e47dac9 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -9,6 +9,11 @@ config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-flash + contextWindow: 256000 + - id: deepseek-v4-pro + contextWindow: 256000 # The default composition confines bash to the workspace and asks before a # wider retry. Snapshots use danger-full-access; DSH_PERMISSION_MODE overrides @@ -48,20 +53,17 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Replay-aware request pressure with one service-wide context window. +# Replay-aware request pressure; the routed adapter supplies model capacity. - id: token-meter name: '@deepseek-ai/dsh-token-meter' - config: - # FIXME: Resolve compaction config per model; this capacity assumes a 256k context window. - contextWindow: 256000 # Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. +# Ratios scale against the routed model's context window. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: thresholdRatio: 0.8 - retainTokens: 20480 + retainRatio: 0.08 maxTokens: 8192 compactionRetries: 1 diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 9533af28d3..49fcac96aa 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -21,6 +21,8 @@ flowchart LR bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_headless_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_headless_token_meter plugin_headless_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_headless_compact_basic plugin_headless_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -52,6 +54,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 7f48c529c4..a4e02d2de0 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -11,7 +11,9 @@ baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - id: deepseek-v4-pro + contextWindow: 128000 - id: deepseek-v4-flash + contextWindow: 128000 - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -34,13 +36,14 @@ factual. # Summarize an older range when derived history approaches the context window. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: - contextWindow: 128000 thresholdRatio: 0.8 - retainTokens: 20480 - summarizationModel: '' + retainRatio: 0.16 maxTokens: 8192 compactionRetries: 1 diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 00ef48f4ed..5c8029db6b 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -63,12 +63,13 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: - contextWindow: 128000 thresholdRatio: 0.8 - retainTokens: 20480 - summarizationModel: '' + retainRatio: 0.16 maxTokens: 8192 compactionRetries: 1 diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml index a8a211a795..a782fb9903 100644 --- a/examples/repl-agent/cordis.yml +++ b/examples/repl-agent/cordis.yml @@ -47,12 +47,12 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Replay-aware request pressure with one service-wide context window. +# Replay-aware request pressure; the routed adapter supplies model capacity. - id: token-meter name: '@deepseek-ai/dsh-token-meter' # Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. +# Ratios scale against the routed model's context window. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/examples/repl-agent/tests/compaction.e2e.ts b/examples/repl-agent/tests/compaction.e2e.ts index d992fc9efa..fcebddb863 100644 --- a/examples/repl-agent/tests/compaction.e2e.ts +++ b/examples/repl-agent/tests/compaction.e2e.ts @@ -33,9 +33,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa // Reasoning tokens require a larger generation cap than the retained checkpoint. ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT, - tokenMeter: { - contextWindow: 2000, - }, + modelContextWindow: 2000, compact: { thresholdRatio: 0.5, retainTokens: 400, diff --git a/examples/repl-agent/tests/harness.ts b/examples/repl-agent/tests/harness.ts index eeba57fc61..d7777acbb6 100644 --- a/examples/repl-agent/tests/harness.ts +++ b/examples/repl-agent/tests/harness.ts @@ -8,7 +8,6 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' @@ -45,8 +44,8 @@ export interface CodingHarnessOptions { * compaction plugin (the default suites run without it). */ compact?: BasicCompactConfig - /** Optional token-meter capacity loaded before compact-basic. */ - tokenMeter?: TokenMeterConfig + /** Test-only context capacity advertised for `deepseek-v4-flash`. */ + modelContextWindow?: number } export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { @@ -55,14 +54,15 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio systemPrompt: { persona: options.persona ?? '' }, }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LlmDeepSeek) + await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { + models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], + }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) - // Compaction is opt-in: only the compaction e2e loads the reusable meter and - // backend, with a lower context window so a short real session crosses the threshold. + // Compaction is opt-in: only the compaction e2e loads the reusable meter and backend. if (options.compact !== undefined) { - await ctx.plugin(TokenMeterService, options.tokenMeter) + await ctx.plugin(TokenMeterService) await ctx.plugin(BasicCompactService, options.compact) } // Durable JSONL persistence is opt-in: only the resume e2e needs it, and the diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 6a67d2dfd4..01729bfd36 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,31 +9,38 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: - **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering. +- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. -- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. +- **Overflow recovery** — provider-confirmed overflow does not require capacity metadata: it bypasses normal pressure and retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. A region failure records an error end and leaves the surface unchanged. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error. The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`. ## Config (`BasicCompactConfig`) -Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected. +Every setting is optional. Top-level policy fields are defaults for every routed model; `modelPolicies` applies partial overrides to exact provider/model pairs. At pressure time, compact-basic asks the owning LLM adapter for that route's context capacity and resolves absolute budgets. Unrecognized keys, duplicate targets, mutually exclusive retention forms, and a merged `retainRatio` that is not below `thresholdRatio` fail plugin load. An absolute `retainTokens` budget that is not below its scaled threshold fails on the first resolvable target because that comparison requires model capacity. | Key | Required | Meaning | |---|---|---| -| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. | -| `retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. | +| `thresholdRatio` | no (default `0.8`) | Compact at `floor(routedContextWindow × ratio)`. | +| `retainRatio` | no (default `0.16`) | Recent surface budget kept verbatim as a fraction of the routed context window; mutually exclusive with `retainTokens`. | +| `retainTokens` | no | Absolute recent surface budget kept verbatim; mutually exclusive with `retainRatio` and must be below the resolved threshold. | | `summarizationProvider` | no (default `''`) | Set together with `summarizationModel`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. | | `summarizationModel` | no (default `''`) | Set together with `summarizationProvider`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. | | `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. | | `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. | | `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. | +| `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. | | `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. | +Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry. + +An adapter may return no capacity for a valid dynamic route, and resolved capacity may expose an invalid absolute retention budget. Manual pressure checks then throw a target-specific configuration error; the automatic listener warns once for that exact target and continues with full history. Unrelated operational failures remain independently visible. Canonical provider overflow still attempts recovery because the provider has already established that compaction is necessary. + ## Usage ```ts @@ -52,6 +59,20 @@ export function apply(ctx: Context): void { Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. +For example, the same compact plugin can safely serve models with different capacities and one target-specific policy: + +```yaml +- name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainRatio: 0.16 + modelPolicies: + - provider: local + model: small-context + thresholdRatio: 0.7 + retainTokens: 2048 +``` + ## Model Experience ### Conversation history diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts index 1587fac272..90adb5b5f7 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -1,111 +1,298 @@ /** - * Runtime defaulting and policy validation for compact-basic. + * Load-time validation and routed-model policy resolution for compact-basic. * * @module @deepseek-ai/dsh-compact-basic/config */ import { deepFreeze } from '@deepseek-ai/dsh-llm' -import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' -import type { BasicCompactConfig, ResolvedConfig } from './types.ts' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import type { + BasicCompactConfig, + CompactPolicyConfig, + ModelCompactPolicyConfig, + ResolvedCompactSpec, + ResolvedConfig, + ResolvedRetention, + ResolvedTargetPolicy, +} from './types.ts' -/** Default request-pressure fraction of the token meter's context window. */ +/** Default request-pressure fraction for every routed model. */ const DEFAULT_THRESHOLD_RATIO = 0.8 -/** Default verbatim-tail fraction of the token meter's context window. */ +/** Default verbatim-tail fraction for every routed model. */ const DEFAULT_RETAIN_RATIO = 0.16 -/** Complete public configuration key set. */ -const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet = new Set([ +/** Fields shared by top-level defaults and exact-target overrides. */ +const POLICY_CONFIG_KEYS = [ 'thresholdRatio', + 'retainRatio', 'retainTokens', 'summarizationProvider', 'summarizationModel', 'maxTokens', 'compactionRetries', 'maxOverflowRetries', +] as const + +/** Complete public top-level configuration key set. */ +const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet = new Set([ + ...POLICY_CONFIG_KEYS, + 'modelPolicies', 'auto', ]) -/** Reject stale or misspelled keys before defaults can hide them. */ -function validateConfigKeys(config: BasicCompactConfig): void { - for (const key of Object.keys(config)) { - if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) { - throw new Error( - `BasicCompactConfig: unknown key "${key}" ` - + '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, ' - + 'maxTokens, compactionRetries, maxOverflowRetries, auto)', - ) - } +/** Complete exact-target override key set. */ +const MODEL_POLICY_KEYS: ReadonlySet = new Set([ + 'provider', + 'model', + ...POLICY_CONFIG_KEYS, +]) + +/** Target-specific pressure configuration failure eligible for warning suppression. */ +export class TargetPressureConfigError extends Error { + /** + * @param targetKey - exact provider/model route used as the warning key. + * @param message - actionable configuration failure detail. + */ + constructor(readonly targetKey: string, message: string) { + super(message) } } /** - * Resolve defaults and validate the service-wide compaction policy. - * @param config - raw compact-basic configuration. - * @param tokenMeter - token meter supplying the context capacity. - * @returns a detached deeply immutable configuration. + * Resolve and validate service defaults plus exact-target partial overrides. + * @param config - untrusted plugin configuration after Loader normalization. + * @returns detached immutable defaults and validated exact-target overrides. */ -export function resolveConfig( - config: BasicCompactConfig = {}, - tokenMeter: TokenMeterService, -): ResolvedConfig { - validateConfigKeys(config) +export function resolveConfig(config: BasicCompactConfig = {}): ResolvedConfig { + validateKeys(config, BASIC_COMPACT_CONFIG_KEYS, 'BasicCompactConfig') + validatePolicy(config, 'BasicCompactConfig') + if (config.auto !== undefined && typeof config.auto !== 'boolean') { + throw new Error('BasicCompactConfig: auto must be a boolean') + } + const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO - const retainTokens = config.retainTokens - ?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO) - const resolved: ResolvedConfig = { + const retention = resolveRetention(config, { retainRatio: DEFAULT_RETAIN_RATIO }) + validateRatioRetention(thresholdRatio, retention, 'BasicCompactConfig') + const modelPolicies = resolveModelPolicies(config.modelPolicies) + for (const [index, policy] of modelPolicies.entries()) { + validateRatioRetention( + policy.thresholdRatio ?? thresholdRatio, + resolveRetention(policy, retention), + `BasicCompactConfig: modelPolicies[${index}]`, + ) + } + + return deepFreeze({ thresholdRatio, - retainTokens, + ...retention, summarizationProvider: config.summarizationProvider ?? '', summarizationModel: config.summarizationModel ?? '', maxTokens: config.maxTokens ?? 8192, compactionRetries: config.compactionRetries ?? 1, maxOverflowRetries: config.maxOverflowRetries ?? 1, + modelPolicies, auto: config.auto ?? true, - } + }) +} - assertRatio('thresholdRatio', resolved.thresholdRatio) - assertNonNegativeInteger('retainTokens', resolved.retainTokens) - const thresholdTokens = Math.floor(tokenMeter.contextWindow * resolved.thresholdRatio) - if (resolved.retainTokens >= thresholdTokens) { - throw new Error( - `BasicCompactConfig: retainTokens (${resolved.retainTokens}) must be less than threshold tokens ${thresholdTokens}`, +/** + * Merge the exact provider/model override over the validated default policy. + * @param config - validated service defaults and override table. + * @param target - exact durable provider/model route to match. + * @returns detached immutable policy before model-capacity scaling. + */ +export function resolveTargetPolicy( + config: ResolvedConfig, + target: Pick, +): ResolvedTargetPolicy { + const override = config.modelPolicies.find(policy => ( + policy.provider === target.provider && policy.model === target.model + )) + const inheritedRetention: ResolvedRetention = config.retainTokens === undefined + ? { retainRatio: config.retainRatio } + : { retainTokens: config.retainTokens } + return deepFreeze({ + target: { provider: target.provider, model: target.model }, + thresholdRatio: override?.thresholdRatio ?? config.thresholdRatio, + ...resolveRetention(override ?? {}, inheritedRetention), + summarizationProvider: override?.summarizationProvider ?? config.summarizationProvider, + summarizationModel: override?.summarizationModel ?? config.summarizationModel, + maxTokens: override?.maxTokens ?? config.maxTokens, + compactionRetries: override?.compactionRetries ?? config.compactionRetries, + maxOverflowRetries: override?.maxOverflowRetries ?? config.maxOverflowRetries, + }) +} + +/** + * Scale one routed policy into concrete token budgets for its model capacity. + * @param policy - merged policy for the exact routed target. + * @param contextWindow - positive adapter-owned capacity for that target. + * @returns detached immutable pressure and retention budgets. + */ +export function resolveCompactSpec( + policy: ResolvedTargetPolicy, + contextWindow: number, +): ResolvedCompactSpec { + const targetKey = `${policy.target.provider}/${policy.target.model}` + if (!Number.isInteger(contextWindow) || contextWindow <= 0) { + throw new TargetPressureConfigError( + targetKey, + `BasicCompactConfig: contextWindow (${contextWindow}) must be a positive integer`, ) } - assertPositiveInteger('maxTokens', resolved.maxTokens) - assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) - assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries) - if (typeof resolved.summarizationProvider !== 'string') { - throw new Error('BasicCompactConfig: summarizationProvider must be a string') - } - if (typeof resolved.summarizationModel !== 'string') { - throw new Error('BasicCompactConfig: summarizationModel must be a string') - } - if ((resolved.summarizationProvider.length === 0) !== (resolved.summarizationModel.length === 0)) { - throw new Error( - 'BasicCompactConfig: summarizationProvider and summarizationModel must both be set or both be empty', + const thresholdTokens = Math.floor(contextWindow * policy.thresholdRatio) + const retainTokens = policy.retainTokens === undefined + ? Math.floor(contextWindow * policy.retainRatio) + : policy.retainTokens + if (retainTokens >= thresholdTokens) { + throw new TargetPressureConfigError( + targetKey, + `BasicCompactConfig: ${policy.target.provider}/${policy.target.model} retainTokens ` + + `(${retainTokens}) must be less than threshold tokens ${thresholdTokens}`, ) } - if (typeof resolved.auto !== 'boolean') { - throw new Error('BasicCompactConfig: auto must be a boolean') - } - return deepFreeze(resolved) + return deepFreeze({ + target: { ...policy.target }, + contextWindow, + thresholdRatio: policy.thresholdRatio, + thresholdTokens, + retainTokens, + summarizationProvider: policy.summarizationProvider, + summarizationModel: policy.summarizationModel, + maxTokens: policy.maxTokens, + compactionRetries: policy.compactionRetries, + maxOverflowRetries: policy.maxOverflowRetries, + }) } -function assertPositiveInteger(name: string, value: number): void { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer`) +/** Choose an explicit retention form or inherit the already-resolved fallback. */ +function resolveRetention( + config: CompactPolicyConfig, + fallback: ResolvedRetention, +): ResolvedRetention { + if (config.retainTokens !== undefined) return { retainTokens: config.retainTokens } + if (config.retainRatio !== undefined) return { retainRatio: config.retainRatio } + return fallback +} + +/** Reject a capacity-independent retention conflict at plugin load. */ +function validateRatioRetention( + thresholdRatio: number, + retention: ResolvedRetention, + name: string, +): void { + if (retention.retainRatio !== undefined && retention.retainRatio >= thresholdRatio) { + throw new Error( + `${name}: retainRatio (${retention.retainRatio}) must be less than ` + + `the resolved thresholdRatio (${thresholdRatio})`, + ) } } -function assertNonNegativeInteger(name: string, value: number): void { - if (!Number.isInteger(value) || value < 0) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer`) +/** Validate, detach, and reject duplicate exact-target policies. */ +function resolveModelPolicies(configured: unknown): ModelCompactPolicyConfig[] { + if (configured === undefined) return [] + if (!Array.isArray(configured)) { + throw new Error('BasicCompactConfig: modelPolicies must be an array') + } + const seen = new Set() + return configured.map((source: unknown, index) => { + const name = `BasicCompactConfig: modelPolicies[${index}]` + assertModelPolicy(source, name) + const key = `${source.provider}\u0000${source.model}` + if (seen.has(key)) { + throw new Error( + `BasicCompactConfig: duplicate model policy for ${source.provider}/${source.model}`, + ) + } + seen.add(key) + return { ...source } + }) +} + +/** Validate one untrusted exact-target override and narrow its public type. */ +function assertModelPolicy( + source: unknown, + name: string, +): asserts source is ModelCompactPolicyConfig { + if (!isUnknownRecord(source)) throw new Error(`${name} must be an object`) + validateKeys(source, MODEL_POLICY_KEYS, name) + assertNonEmptyString(`${name}.provider`, source.provider) + assertNonEmptyString(`${name}.model`, source.model) + validatePolicy(source, name) +} + +/** Validate the fields common to defaults and exact-target partial overrides. */ +function validatePolicy( + config: CompactPolicyConfig | Record, + name: string, +): void { + const thresholdRatio = config.thresholdRatio + const retainRatio = config.retainRatio + const retainTokens = config.retainTokens + const maxTokens = config.maxTokens + const compactionRetries = config.compactionRetries + const maxOverflowRetries = config.maxOverflowRetries + if (thresholdRatio !== undefined) assertRatio(`${name}.thresholdRatio`, thresholdRatio) + if (retainRatio !== undefined) assertRatio(`${name}.retainRatio`, retainRatio) + if (retainTokens !== undefined) assertNonNegativeInteger(`${name}.retainTokens`, retainTokens) + if (retainRatio !== undefined && retainTokens !== undefined) { + throw new Error(`${name}: retainRatio and retainTokens are mutually exclusive`) + } + if (maxTokens !== undefined) assertPositiveInteger(`${name}.maxTokens`, maxTokens) + if (compactionRetries !== undefined) { + assertNonNegativeInteger(`${name}.compactionRetries`, compactionRetries) + } + if (maxOverflowRetries !== undefined) { + assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries) + } + + const summarizationProvider = config.summarizationProvider + const summarizationModel = config.summarizationModel + if (summarizationProvider !== undefined && typeof summarizationProvider !== 'string') { + throw new Error(`${name}.summarizationProvider must be a string`) + } + if (summarizationModel !== undefined && typeof summarizationModel !== 'string') { + throw new Error(`${name}.summarizationModel must be a string`) + } + if (((summarizationProvider ?? '').length === 0) + !== ((summarizationModel ?? '').length === 0)) { + throw new Error(`${name}: summarizationProvider and summarizationModel must both be empty or both be non-empty`) } } -function assertRatio(name: string, value: number): void { +/** Reject stale or misspelled keys before defaults can hide them. */ +function validateKeys(config: object, keys: ReadonlySet, name: string): void { + for (const key of Object.keys(config)) { + if (!keys.has(key)) throw new Error(`${name}: unknown key "${key}"`) + } +} + +function isUnknownRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function assertNonEmptyString(name: string, value: unknown): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} must be a non-empty string`) + } +} + +function assertPositiveInteger(name: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + throw new Error(`${name} (${String(value)}) must be a positive integer`) + } +} + +function assertNonNegativeInteger(name: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new Error(`${name} (${String(value)}) must be a non-negative integer`) + } +} + +function assertRatio(name: string, value: unknown): asserts value is number { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1]`) + throw new Error(`${name} (${String(value)}) must be a number in (0, 1]`) } } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 5d325d57ba..1e9af13888 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -10,27 +10,76 @@ import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' import type { Session } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { resolveConfig } from './config.ts' +import { + resolveCompactSpec, + resolveConfig, + resolveTargetPolicy, + TargetPressureConfigError, +} from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' import type { BasicCompactConfig, + ModelCompactPolicyConfig, ResolvedConfig, } from './types.ts' export type { BasicCompactConfig, + CompactPolicyConfig, + ModelCompactPolicyConfig, + ResolvedCompactSpec, ResolvedConfig, + ResolvedRetention, + ResolvedTargetPolicy, } from './types.ts' -/** Resolve the exact model durably routed for the latest provider request. */ -function routedModel(session: Session): string | undefined { - const model = session.requestHeader()?.config.model - return model === undefined || model.length === 0 ? undefined : model +/** Resolve the exact provider/model durably routed for the latest request. */ +function routedTarget( + session: Session, +): Pick | undefined { + const config = session.requestHeader()?.config + if (config === undefined || config.provider.length === 0 || config.model.length === 0) { + return undefined + } + return { provider: config.provider, model: config.model } } +/** Resolve the conversation target used to select an optional policy override. */ +function conversationTarget( + agent: Agent, +): Pick | undefined { + const routed = routedTarget(agent.session) + if (routed !== undefined) return routed + if (agent.options.provider === undefined || agent.options.provider.length === 0 + || agent.options.model === undefined || agent.options.model.length === 0) return undefined + return { provider: agent.options.provider, model: agent.options.model } +} + +const thresholdRatioSchema = z.number() +const retainRatioSchema = z.number() +const retainTokensSchema = z.number().step(1).min(0) +const summarizationProviderSchema = z.string() +const summarizationModelSchema = z.string() +const maxTokensSchema = z.number().step(1).min(1) +const compactionRetriesSchema = z.number().step(1).min(0) +const maxOverflowRetriesSchema = z.number().step(1).min(0) + +const modelPolicy: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + thresholdRatio: thresholdRatioSchema, + retainRatio: retainRatioSchema, + retainTokens: retainTokensSchema, + summarizationProvider: summarizationProviderSchema, + summarizationModel: summarizationModelSchema, + maxTokens: maxTokensSchema, + compactionRetries: compactionRetriesSchema, + maxOverflowRetries: maxOverflowRetriesSchema, +}) + /** * Dependency-light compaction backend using `ctx.tokenMeter` for pressure, * retention, provenance, and summary-convergence pricing. @@ -43,22 +92,26 @@ export class BasicCompactService extends CompactService { static inject = ['llm', 'tokenMeter'] static Config: z = z.object({ - thresholdRatio: z.number().default(0.8), - retainTokens: z.number().step(1), - summarizationProvider: z.string().default(''), - summarizationModel: z.string().default(''), - maxTokens: z.number().step(1).min(1).default(8192), - compactionRetries: z.number().step(1).min(0).default(1), - maxOverflowRetries: z.number().step(1).min(0).default(1), - auto: z.boolean().default(true), + thresholdRatio: thresholdRatioSchema, + retainRatio: retainRatioSchema, + retainTokens: retainTokensSchema, + summarizationProvider: summarizationProviderSchema, + summarizationModel: summarizationModelSchema, + maxTokens: maxTokensSchema, + compactionRetries: compactionRetriesSchema, + maxOverflowRetries: maxOverflowRetriesSchema, + modelPolicies: z.array(modelPolicy), + auto: z.boolean(), }) /** Resolved and validated compaction configuration. */ readonly config: ResolvedConfig + private readonly warnedPressureConfigTargets = new Set() + constructor(ctx: Context, config: BasicCompactConfig = {}) { super(ctx) - this.config = resolveConfig(config, ctx.tokenMeter) + this.config = resolveConfig(config) if (this.config.auto) this._registerAutomaticCompaction() } @@ -88,15 +141,21 @@ export class BasicCompactService extends CompactService { const result = await this.compactIfNeeded(agent, 'pressure', signal) if (result !== null) logResult(result, 'post-step pressure') } catch (error: unknown) { + if (error instanceof TargetPressureConfigError) { + if (this.warnedPressureConfigTargets.has(error.targetKey)) return + this.warnedPressureConfigTargets.add(error.targetKey) + } const message = error instanceof Error ? error.message : String(error) ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`) } }) ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => { - if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE - || retryAttempt >= this.config.maxOverflowRetries - || signal.aborted) return next() + if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() + const target = routedTarget(agent.session) + if (target === undefined) return next() + const policy = resolveTargetPolicy(this.config, target) + if (retryAttempt >= policy.maxOverflowRetries) return next() let generation: number let result: CompactionResult | null @@ -131,7 +190,11 @@ export class BasicCompactService extends CompactService { agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - return summarizeWithLlm(this.ctx, this.config, text, agent, signal) + const target = conversationTarget(agent) + const config = target === undefined + ? this.config + : resolveTargetPolicy(this.config, target) + return summarizeWithLlm(this.ctx, config, text, agent, signal) } /** @@ -149,8 +212,9 @@ export class BasicCompactService extends CompactService { trigger: CompactionTrigger, signal: AbortSignal, ): Promise { - const model = routedModel(agent.session) - if (model === undefined) return null + const target = routedTarget(agent.session) + if (target === undefined) return null + const policy = resolveTargetPolicy(this.config, target) const meter = this.ctx.tokenMeter switch (trigger) { case 'context-overflow': { @@ -166,13 +230,22 @@ export class BasicCompactService extends CompactService { assertNever(trigger, 'compaction trigger') } - const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) + const context = await this.ctx.llm.resolveModelContext(target.provider, target.model) + const targetKey = `${target.provider}/${target.model}` + if (context === undefined) { + throw new TargetPressureConfigError( + targetKey, + `compact-basic: no context capacity for ${targetKey}; ` + + 'configure contextWindow on that adapter model', + ) + } + const spec = resolveCompactSpec(policy, context.contextWindow) let measurement = meter.measure(agent.session) - if (measurement.totalTokens < threshold) return null + if (measurement.totalTokens < spec.thresholdTokens) return null let result: CompactionResult | null = null - for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) { - const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens) + for (let attempt = 0; attempt <= spec.compactionRetries; attempt += 1) { + const range = selectCompactableRange(agent.session, measurement, spec.retainTokens) if (range === null) { /* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */ if (result === null) return null @@ -181,12 +254,12 @@ export class BasicCompactService extends CompactService { } result = await this.compactRegion(range.start, range.end, agent, signal) measurement = meter.measure(agent.session) - if (measurement.totalTokens < threshold) return result + if (measurement.totalTokens < spec.thresholdTokens) return result } throw new Error( - `compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts ` - + `(${measurement.totalTokens} estimated tokens >= threshold ${threshold})`, + `compaction still above threshold after ${spec.compactionRetries + 1} compaction attempts ` + + `(${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})`, ) } diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 62b5f5f5e2..7a4de4223d 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -8,7 +8,12 @@ import type { Context } from 'cordis' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ResolvedConfig } from './types.ts' + +interface SummaryConfig { + readonly summarizationProvider: string + readonly summarizationModel: string + readonly maxTokens: number +} /** Tags wrapping the structured summary inside the landed checkpoint node. */ const SUMMARY_OPEN_TAG = '' @@ -74,7 +79,7 @@ export interface SummaryResult { */ export async function summarizeWithLlm( ctx: Context, - config: ResolvedConfig, + config: SummaryConfig, text: string, agent: Agent, signal?: AbortSignal, diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 6ed0165226..c322f508ac 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -4,15 +4,19 @@ * @module @deepseek-ai/dsh-compact-basic/types */ -/** Basic compaction configuration; every common field has a deployment default. */ -export interface BasicCompactConfig { - /** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */ +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' + +/** Policy fields shared by the default policy and exact model overrides. */ +export interface CompactPolicyConfig { + /** Compact at this fraction of the model's context window. Defaults to `0.8`. */ thresholdRatio?: number - /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ + /** Recent context retained as a fraction of the model's window. Defaults to `0.16`. */ + retainRatio?: number + /** Absolute recent-context budget; mutually exclusive with `retainRatio`. */ retainTokens?: number - /** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary provider; set together with `summarizationModel`, or inherit the conversation target. */ summarizationProvider?: string - /** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary model; set together with `summarizationProvider`, or inherit the conversation target. */ summarizationModel?: string /** Provider generation cap for summarization. Defaults to `8192`. */ maxTokens?: number @@ -20,18 +24,53 @@ export interface BasicCompactConfig { compactionRetries?: number /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ maxOverflowRetries?: number +} + +/** Exact provider/model override merged over the default compaction policy. */ +export interface ModelCompactPolicyConfig extends CompactPolicyConfig { + /** Registered provider route to match. */ + provider: string + /** Exact routed model id to match within `provider`. */ + model: string +} + +/** Basic compaction configuration with an optional exact-target policy table. */ +export interface BasicCompactConfig extends CompactPolicyConfig { + /** Exact provider/model overrides; duplicate targets fail plugin load. */ + modelPolicies?: ModelCompactPolicyConfig[] /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } -/** Validated and detached compaction configuration. */ -export interface ResolvedConfig { +/** Exactly one validated retention form. */ +export type ResolvedRetention = + | { readonly retainRatio: number; readonly retainTokens?: never } + | { readonly retainRatio?: never; readonly retainTokens: number } + +/** Validated policy fields shared before and after exact-target matching. */ +interface ResolvedPolicyFields { readonly thresholdRatio: number - readonly retainTokens: number readonly summarizationProvider: string readonly summarizationModel: string readonly maxTokens: number readonly compactionRetries: number readonly maxOverflowRetries: number +} + +/** Validated immutable config whose target-specific defaults remain unresolved. */ +export type ResolvedConfig = ResolvedPolicyFields & ResolvedRetention & { + readonly modelPolicies: readonly Readonly[] readonly auto: boolean } + +/** Fully merged policy for one routed conversation target, before capacity scaling. */ +export type ResolvedTargetPolicy = ResolvedPolicyFields & ResolvedRetention & { + readonly target: Pick +} + +/** One routed model's concrete pressure and retention budget. */ +export type ResolvedCompactSpec = Omit & { + readonly contextWindow: number + readonly thresholdTokens: number + readonly retainTokens: number +} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 0a411440b3..39d807e9c1 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -4,10 +4,14 @@ import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' -import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' +import { + resolveCompactSpec, + resolveConfig, + resolveTargetPolicy, +} from '@deepseek-ai/dsh-compact-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmModelContext, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -15,9 +19,40 @@ import type { Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal const MODEL = 'test-model' +class ContextAdapter extends LlmAdapter { + constructor(private readonly contextWindow: number) { + super() + } + + override resolveModelContext(): Promise { + return Promise.resolve({ contextWindow: this.contextWindow }) + } + + override async * stream(): AsyncIterable { + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +class RoutedContextAdapter extends LlmAdapter { + constructor(private readonly windows: Readonly>) { + super() + } + + override resolveModelContext(provider: string): Promise { + const contextWindow = this.windows[provider] + return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + } + + override async * stream(): AsyncIterable { + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + function createContext(contextWindow = 1_000): Context { const ctx = new Context() - void new TokenMeterService(ctx, { contextWindow }) + void new LlmService(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter([MODEL, 'actual', 'unlisted-provider'], new ContextAdapter(contextWindow)) return ctx } @@ -140,43 +175,96 @@ async function compactIfNeeded( describe('compact configuration and defaults', () => { it('uses low-friction service-wide defaults', () => { - const ctx = createContext() - const resolved = resolveConfig({}, ctx.tokenMeter) + const resolved = resolveConfig({}) expect(resolved).toEqual({ thresholdRatio: 0.8, - retainTokens: 160, + retainRatio: 0.16, summarizationProvider: '', summarizationModel: '', maxTokens: 8192, compactionRetries: 1, maxOverflowRetries: 1, + modelPolicies: [], auto: true, }) expect(Object.isFrozen(resolved)).toBe(true) }) it('resolves threshold and retention overrides independently', () => { - const ctx = createContext() const thresholdOnly = resolveConfig({ thresholdRatio: 0.5, - }, ctx.tokenMeter) + }) expect(thresholdOnly).toMatchObject({ thresholdRatio: 0.5, - retainTokens: 160, + retainRatio: 0.16, }) const retentionOnly = resolveConfig({ retainTokens: 70, - }, ctx.tokenMeter) + }) expect(retentionOnly).toMatchObject({ thresholdRatio: 0.8, retainTokens: 70, }) + expect(retentionOnly).not.toHaveProperty('retainRatio') + }) + + it('merges exact provider/model policy overrides and scales ratios per model', () => { + const config = resolveConfig({ + thresholdRatio: 0.8, + retainRatio: 0.1, + modelPolicies: [{ + provider: 'small-provider', + model: 'shared-id', + thresholdRatio: 0.5, + retainTokens: 120, + }], + }) + const small = resolveTargetPolicy(config, { + provider: 'small-provider', + model: 'shared-id', + }) + const otherProvider = resolveTargetPolicy(config, { + provider: 'large-provider', + model: 'shared-id', + }) + + expect(resolveCompactSpec(small, 1_000)).toMatchObject({ + thresholdTokens: 500, + retainTokens: 120, + }) + expect(resolveCompactSpec(otherProvider, 2_000)).toMatchObject({ + thresholdTokens: 1_600, + retainTokens: 200, + }) + + const ratioOverride = resolveTargetPolicy(resolveConfig({ + retainTokens: 200, + modelPolicies: [{ + provider: 'ratio-provider', + model: 'ratio-model', + thresholdRatio: 0.6, + retainRatio: 0.2, + summarizationProvider: 'summary-provider', + summarizationModel: 'summary-model', + maxTokens: 512, + compactionRetries: 2, + maxOverflowRetries: 3, + }], + }), { provider: 'ratio-provider', model: 'ratio-model' }) + expect(resolveCompactSpec(ratioOverride, 2_000)).toMatchObject({ + thresholdTokens: 1_200, + retainTokens: 400, + summarizationProvider: 'summary-provider', + summarizationModel: 'summary-model', + maxTokens: 512, + compactionRetries: 2, + maxOverflowRetries: 3, + }) }) it('validates common values and pressure-policy invariants', () => { - const ctx = createContext() const bad = [ [{ maxTokens: 0 }, /maxTokens/], [{ compactionRetries: -1 }, /compactionRetries/], @@ -184,19 +272,48 @@ describe('compact configuration and defaults', () => { [{ auto: 'yes' }, /auto must be a boolean/], [{ summarizationProvider: 1 }, /summarizationProvider must be a string/], [{ summarizationModel: 1 }, /summarizationModel must be a string/], - [{ summarizationProvider: MODEL }, /must both be set or both be empty/], - [{ summarizationModel: MODEL }, /must both be set or both be empty/], + [{ summarizationProvider: MODEL }, /must both be empty or both be non-empty/], + [{ summarizationModel: MODEL }, /must both be empty or both be non-empty/], [{ thresholdRatio: 0 }, /number in \(0, 1\]/], [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/], + [{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/], + [{ thresholdRatio: 0.1 }, /retainRatio \(0.16\) must be less than the resolved thresholdRatio \(0.1\)/], [{ retainTokens: -1 }, /non-negative integer/], - [{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/], + [{ retainRatio: 0.2, retainTokens: 100 }, /mutually exclusive/], + [{ modelPolicies: {} }, /modelPolicies must be an array/], + [{ modelPolicies: [1] }, /modelPolicies\[0\] must be an object/], + [{ modelPolicies: [null] }, /modelPolicies\[0\] must be an object/], + [{ modelPolicies: [[]] }, /modelPolicies\[0\] must be an object/], + [{ modelPolicies: [{ provider: 1, model: MODEL }] }, /provider must be a non-empty string/], + [{ modelPolicies: [{ provider: '', model: MODEL }] }, /provider must be a non-empty string/], + [{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/], + [{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/], + [{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/], + [{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/], + [ + { modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] }, + /modelPolicies\[0\]: retainRatio \(0.16\).*thresholdRatio \(0.1\)/, + ], + [ + { modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.9 }] }, + /modelPolicies\[0\]: retainRatio \(0.9\).*thresholdRatio \(0.8\)/, + ], + [{ modelPolicies: [{ provider: MODEL, model: MODEL }, { provider: MODEL, model: MODEL }] }, /duplicate model policy/], [{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/], [{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/], ] as Array<[unknown, RegExp]> for (const [config, pattern] of bad) { - expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern) + expect(() => resolveConfig(config as BasicCompactConfig)).toThrow(pattern) } + + const invalidPressure = resolveTargetPolicy(resolveConfig({ + thresholdRatio: 0.5, + retainTokens: 500, + }), { provider: MODEL, model: MODEL }) + expect(() => resolveCompactSpec(invalidPressure, 1_000)).toThrow(/less than threshold/) + expect(() => resolveCompactSpec(invalidPressure, 1.5)).toThrow(/positive integer/) + expect(() => resolveCompactSpec(invalidPressure, 0)).toThrow(/positive integer/) }) }) @@ -216,7 +333,7 @@ describe('pressure measurement and retention', () => { expect(compact.calls).toHaveLength(0) }) - it('meters any routed model without profile resolution', async () => { + it('meters an unlisted model when its provider adapter supplies context metadata', async () => { const compact = service(compactConfig) const session = conversation() session.append('request/header', { @@ -227,6 +344,52 @@ describe('pressure measurement and retention', () => { .resolves.not.toBeNull() }) + it('re-resolves capacity after a same-model-id provider switch in one session', async () => { + const ctx = new Context() + void new LlmService(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter(['large', 'small'], new RoutedContextAdapter({ + large: 10_000, + small: 1_000, + })) + const compact = service({ + auto: false, + thresholdRatio: 0.5, + retainRatio: 0.1, + }, ctx) + const session = conversation(4) + session.append('request/header', { + header: { config: { provider: 'large', model: 'shared-id' } }, + reason: 'resume', + }) + await expect(compactIfNeeded(compact, session)).resolves.toBeNull() + + session.append('request/header', { + header: { config: { provider: 'small', model: 'shared-id' } }, + reason: 'change', + }) + await expect(compactIfNeeded(compact, session)).resolves.not.toBeNull() + }) + + it('requires capacity only for proactive pressure, not provider-confirmed overflow', async () => { + const ctx = new Context() + void new LlmService(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000)) + vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) + const compact = service(compactConfig, ctx) + const session = conversation(4) + session.append('request/header', { + header: { config: { provider: 'unknown-context', model: 'model' } }, + reason: 'resume', + }) + + await expect(compactIfNeeded(compact, session, 'pressure')) + .rejects.toThrow(/no context capacity for unknown-context\/model/) + await expect(compactIfNeeded(compact, session, 'context-overflow')) + .resolves.not.toBeNull() + }) + it('declines forced overflow when the whole surface is one indivisible tool pair', async () => { const compact = service(compactConfig) const session = new Session(SessionId('single-tool-pair')) @@ -678,7 +841,7 @@ async function summarizerHarness( ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> { const ctx = new Context() await ctx.plugin(LlmService) - void new TokenMeterService(ctx, { contextWindow: 1_000 }) + void new TokenMeterService(ctx) const adapter = new ScriptedAdapter(blocks, finish) ctx.llm.registerAdapter([model], adapter) const compact = new ExposedCompactService(ctx, config) @@ -761,6 +924,31 @@ describe('default one-shot summarizer', () => { .rejects.toThrow(/no provider\/model available for summarization/) }) + it('uses a complete AgentOptions target when no durable route exists', async () => { + const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) + const session = new Session(SessionId('headerless-summary')) + + await expect(compact.runSummarize('history', agent(session, MODEL))).resolves.toMatchObject({ + provider: MODEL, + model: MODEL, + }) + expect(adapter.lastOptions).toMatchObject({ provider: MODEL, model: MODEL }) + }) + + it.each([ + { provider: '', model: MODEL }, + { provider: MODEL }, + { provider: MODEL, model: '' }, + ])('rejects incomplete AgentOptions target %#', async (options) => { + const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }]) + const owner = { + session: new Session(SessionId(`incomplete-${String(options.model)}`)), + options, + } as Agent + await expect(compact.runSummarize('history', owner)) + .rejects.toThrow(/no provider\/model available for summarization/) + }) + it.each([ [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/], [{ kind: 'error', message: 'opaque' }, undefined, /opaque/], @@ -857,6 +1045,43 @@ describe('automatic listener and loader composition', () => { expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) + it('warns once per routed target when proactive pressure has no context metadata', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) + void new TestCompactService(ctx, { + thresholdRatio: 0.5, + retainTokens: 180, + }) + const session = conversation(4) + + await postStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) + + expect(warnings).toEqual([ + expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`), + ]) + }) + + it('warns once per routed target when absolute retention exceeds its resolved threshold', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + void new TestCompactService(ctx, { + thresholdRatio: 0.5, + retainTokens: 500, + }) + const session = conversation(4) + + await postStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) + + expect(warnings).toEqual([ + expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'), + ]) + }) + it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => { const ctx = createContext(10_000) void new TestCompactService(ctx, { @@ -989,6 +1214,18 @@ describe('automatic listener and loader composition', () => { .toEqual({ action: 'retry' }) }) + it('delegates canonical overflow when no durable routed target exists', async () => { + const ctx = createContext() + void new TestCompactService(ctx) + const session = new Session(SessionId('headerless-overflow')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + + await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' }) + }) + it('honors retry caps, non-context failures, and cancellation', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 }) @@ -1051,7 +1288,6 @@ describe('automatic listener and loader composition', () => { const meterFiber = await ctx.plugin(TokenMeterService) const compactFiber = await ctx.plugin(BasicCompactService, { auto: false }) - expect(ctx.tokenMeter.contextWindow).toBe(128_000) expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) await compactFiber.dispose() expect(ctx.get('compact')).toBeUndefined() @@ -1062,7 +1298,7 @@ describe('automatic listener and loader composition', () => { it('removes its automatic listener with the plugin fiber', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(TokenMeterService, { contextWindow: 1_000 }) + await ctx.plugin(TokenMeterService) const fiber = await ctx.plugin(TestCompactService, { thresholdRatio: 0.5, retainTokens: 180, 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 1327f073c5..e73fd82687 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -37,6 +37,10 @@ class StepwiseToolAdapter extends LlmAdapter { super() } + override resolveModelContext(): Promise<{ contextWindow: number }> { + return Promise.resolve({ contextWindow: 400 }) + } + async * stream(_options: GenerateOptions): AsyncIterable { const n = this.calls this.calls += 1 @@ -65,6 +69,10 @@ class OverflowRecoveryAdapter extends LlmAdapter { super() } + override resolveModelContext(): Promise<{ contextWindow: number }> { + return Promise.resolve({ contextWindow: 128 }) + } + override async * stream(options: GenerateOptions): AsyncIterable { if (options.system?.includes('You are a compaction engine')) { this.summaryRequests.push(options) @@ -100,7 +108,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(TokenMeterService, { contextWindow: 400 }) + await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) ctx.tools.register(defineTool({ name: 'work', @@ -225,7 +233,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(TokenMeterService, { contextWindow: 128 }) + await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' })) await ctx.plugin(BasicCompactService, { diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index b13e8f8b67..be630919bd 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -54,12 +54,10 @@ describe('real Loader composition', () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-token-meter'", - ' config:', - ' contextWindow: 4096', "- name: '@deepseek-ai/dsh-compact-basic'", ' config:', ' thresholdRatio: 0.5', - ' retainTokens: 512', + ' retainRatio: 0.125', ' auto: false', ]) @@ -67,11 +65,10 @@ describe('real Loader composition', () => { .filter(entry => entry.fiber === undefined && !entry.disabled) .map(entry => entry.options.name) expect(unloaded).toEqual([]) - expect(loaded.tokenMeter.contextWindow).toBe(4096) expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService) expect((loaded.compact as BasicCompactService).config).toMatchObject({ thresholdRatio: 0.5, - retainTokens: 512, + retainRatio: 0.125, auto: false, }) }) @@ -79,8 +76,8 @@ describe('real Loader composition', () => { it('rejects stale token-meter config after Schemastery normalization', async () => { context = new Context() await expect(context.plugin(TokenMeterService, { - models: { legacy: { contextWindow: 4096 } }, - } as never)).rejects.toThrow(/TokenMeterConfig: unknown key "models"/) + contextWindow: 4096, + })).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) }) it('rejects stale compact-basic config after Schemastery normalization', async () => { @@ -91,4 +88,18 @@ describe('real Loader composition', () => { models: { legacy: { thresholdRatio: 0.5 } }, } as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/) }) + + it('rejects a capacity-independent merged ratio conflict during plugin load', async () => { + context = new Context() + await context.plugin(LlmService) + await context.plugin(TokenMeterService) + await expect(context.plugin(BasicCompactService, { + retainRatio: 0.2, + modelPolicies: [{ + provider: 'test-provider', + model: 'test-model', + thresholdRatio: 0.1, + }], + })).rejects.toThrow(/modelPolicies\[0\]: retainRatio \(0.2\).*thresholdRatio \(0.1\)/) + }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c0c29331ae..3699463d91 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -266,6 +266,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async listModels(provider: string): Promise', jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */', }, + { + signature: 'async resolveModelContext( provider: string, model: string, ): Promise', + jsDoc: '/**\n * Resolve context capacity from the adapter that owns one exact route.\n * This query is independent of the advisory model catalog: an unlisted model\n * may return metadata, while `undefined` never rejects later routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached context metadata, or `undefined` when the adapter has none.\n */', + }, { signature: 'stream(options: GenerateOptions): AsyncIterable', jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', @@ -1184,6 +1188,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', }, + { + name: 'LlmModelContext', + declaration: 'export interface LlmModelContext {\n contextWindow: number;\n}', + }, { name: 'LlmModelInfo', declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', diff --git a/packages/llm/README.md b/packages/llm/README.md index ac08ffafc2..2e239b3cb2 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -9,4 +9,4 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a | `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | -The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership. +The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The same route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 27bf4b626a..5dfdf954f2 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -19,11 +19,15 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek V4 Flash + contextWindow: 128000 - id: private-reasoner description: Company-hosted reasoning model + contextWindow: 64000 ``` -The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. +The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. + +`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns it only for an exact configured id; omission or an unlisted pass-through model returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. `reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default). diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 918c8eee82..da780fff45 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -6,7 +6,13 @@ */ import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelContext, + LlmModelInfo, + LlmProviderInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' @@ -21,6 +27,8 @@ export interface DeepSeekCatalogModel { name?: string /** Optional selector detail for deployments with similar model variants. */ description?: string + /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ + contextWindow?: number } /** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */ @@ -79,6 +87,14 @@ export class DeepSeekAdapter extends LlmAdapter { }))) } + override resolveModelContext( + _provider: string, + model: string, + ): Promise { + const contextWindow = this.options.models?.find(entry => entry.id === model)?.contextWindow + return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + } + async * stream(options: GenerateOptions): AsyncIterable { const body = serializeRequest(options, this.options.defaults ?? {}) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index f9f223b6ff..c066ed026a 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -20,8 +20,8 @@ export const name = 'llm-deepseek' export const inject = ['llm'] const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ - { id: 'deepseek-v4-flash' }, - { id: 'deepseek-v4-pro' }, + { id: 'deepseek-v4-flash', contextWindow: 128_000 }, + { id: 'deepseek-v4-pro', contextWindow: 128_000 }, ] /** @@ -47,6 +47,7 @@ const catalogModel: z = z.object({ id: z.string().required(), name: z.string(), description: z.string(), + contextWindow: z.number().step(1).min(1), }) export const Config: z = z.object({ @@ -68,12 +69,19 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee if (model.name !== undefined && model.name.length === 0) { throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`) } + if (model.contextWindow !== undefined + && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) { + throw new Error( + `llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`, + ) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { id: model.id, ...model.name === undefined ? {} : { name: model.name }, ...model.description === undefined ? {} : { description: model.description }, + ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, } }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 954a0ecebd..fb8fbcb872 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -312,6 +312,8 @@ describe('plugin registration and config', () => { { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, ]) + await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash')) + .resolves.toEqual({ contextWindow: 128_000 }) }) it('uses the default model catalog when apply is called directly', async () => { @@ -331,14 +333,23 @@ describe('plugin registration and config', () => { apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [ - { id: 'private-fast' }, - { id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, + { id: 'private-fast', contextWindow: 32_000 }, + { + id: 'private-reasoner', + name: 'Private Reasoner', + description: 'Higher reasoning budget', + contextWindow: 64_000, + }, ], }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ { provider: 'deepseek', id: 'private-fast', name: 'private-fast' }, { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, ]) + await expect(ctx.llm.resolveModelContext('deepseek', 'private-fast')) + .resolves.toEqual({ contextWindow: 32_000 }) + await expect(ctx.llm.resolveModelContext('deepseek', 'arbitrary-unlisted')) + .resolves.toBeUndefined() }) it('allows an explicit empty model catalog', async () => { @@ -355,6 +366,8 @@ describe('plugin registration and config', () => { it.each([ [[{ id: '' }], /ids must be non-empty/], [[{ id: 'm', name: '' }], /empty name/], + [[{ id: 'm', contextWindow: 0 }], /contextWindow/], + [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], ] as const)('rejects invalid advisory model config', async (models, message) => { const ctx = new Context() @@ -367,6 +380,19 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + it('rejects invalid context capacity when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + expect(() => { + LlmDeepSeek.apply(ctx, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + models: [{ id: 'invalid-context', contextWindow: 0 }], + }) + }).toThrow(/contextWindow must be a positive integer/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 06395d701c..2146190b26 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -28,7 +28,7 @@ Configure credentials and deployment-specific transport settings per provider. O Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. -The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. +The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelContext(provider, model)` performs the same exact descriptor lookup and returns its context window, keeping capacity metadata on the route-owning adapter rather than a consuming plugin. Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `maxRetries`, and `maxRetryDelayMs`. They map to pi-ai's common stream options. Harness app attribution wins a conflicting configured header name. diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 7f40c67da3..c284ba3720 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -15,7 +15,7 @@ import type { SimpleStreamOptions, } from '@earendil-works/pi-ai' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import type { PiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' @@ -87,6 +87,22 @@ export class PiAiAdapter extends LlmAdapter { }))) } + override resolveModelContext( + provider: string, + model: string, + ): Promise { + const profile = this.profiles.get(provider) + if (profile === undefined) { + return Promise.reject(new LlmError( + `pi-ai adapter does not own provider "${provider}"`, + 'NO_ADAPTER', + )) + } + return Promise.resolve().then(() => ({ + contextWindow: resolveModel(profile, model).contextWindow, + })) + } + async * stream(options: GenerateOptions): AsyncIterable { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 51f78e7760..dfccc134d2 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -260,6 +260,9 @@ describe('provider profile lifecycle', () => { provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', }) expect(models.every(model => model.provider === 'openai')).toBe(true) + const context = await ctx.llm.resolveModelContext('openai', 'gpt-4.1') + expect(context).toBeDefined() + expect(typeof context?.contextWindow).toBe('number') }) it('accepts absent credentials for pi-ai ambient authentication', async () => { @@ -296,6 +299,10 @@ describe('provider profile lifecycle', () => { it('constructs the adapter directly and rejects routes it does not own', async () => { const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(adapter.resolveModelContext('anthropic', 'claude-sonnet-4')) + .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(adapter.resolveModelContext('openai', 'not-a-catalog-model')) + .rejects.toMatchObject({ code: 'UNKNOWN_MODEL' }) await expect((async () => { for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ } })()).rejects.toMatchObject({ code: 'NO_ADAPTER' }) diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index f9142dbc52..7d922fbdfe 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -11,12 +11,15 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. +- `ctx.llm.resolveModelContext(provider: string, model: string): Promise` Resolve authoritative context capacity for one exact route from its owning adapter. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification does not replace the adapter's original coded `Error`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. +Context capacity is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelContext()` asks the adapter that owns the exact provider/model route; an adapter can describe an unlisted dynamic model, and `undefined` means only that capacity is unavailable. Invalid returned capacity fails with `INVALID_MODEL_CONTEXT`. + ### Events | Event | Mode | Purpose | @@ -25,7 +28,7 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata; their defaults use the route id as its name and advertise no models. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use the route id as its name, advertise no models, and return no capacity. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc. ### Content-block vocabulary (`types.ts`) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index f276aa9f92..5f53e44710 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,7 +7,14 @@ */ import { Context, Service } from 'cordis' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' +import type { + GenerateOptions, + LlmModelContext, + LlmModelInfo, + LlmProviderInfo, + Message, + StreamChunk, +} from './types.ts' import { deepFreeze } from './call-config.ts' import { HarnessError } from './error.ts' import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' @@ -82,6 +89,20 @@ export abstract class LlmAdapter { return Promise.resolve([]) } + /** + * Resolve context capacity for one model accepted by this adapter. Absence + * means the adapter does not know the capacity, not that routing is invalid. + * @param _provider - one provider route owned by this adapter. + * @param _model - exact model id passed to {@link GenerateOptions.model}. + * @returns provider-owned context metadata, or `undefined` when unavailable. + */ + resolveModelContext( + _provider: string, + _model: string, + ): Promise { + return Promise.resolve(undefined) + } + /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. @@ -177,6 +198,29 @@ export class LlmService extends Service { }) } + /** + * Resolve context capacity from the adapter that owns one exact route. + * This query is independent of the advisory model catalog: an unlisted model + * may return metadata, while `undefined` never rejects later routing. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached context metadata, or `undefined` when the adapter has none. + */ + async resolveModelContext( + provider: string, + model: string, + ): Promise { + const context = await this.registration(provider).adapter.resolveModelContext(provider, model) + if (context === undefined) return undefined + if (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0) { + throw new LlmError( + `adapter returned invalid context metadata for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_CONTEXT', + ) + } + return { contextWindow: context.contextWindow } + } + private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } { const registration = this.adapters.get(provider) if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER') diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index b8054c2046..b95ad95c7f 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -141,6 +141,12 @@ export interface LlmModelInfo { description?: string } +/** Provider-owned context capacity for one exact provider/model route. */ +export interface LlmModelContext { + /** Maximum combined request and response context in tokens. */ + contextWindow: number +} + /** * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 6e14d749ba..952d3e981c 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -9,7 +9,7 @@ import LlmService, { LlmError, StreamChunk, } from '@deepseek-ai/dsh-llm' -import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' class ScriptedAdapter extends LlmAdapter { constructor(private script: StreamChunk[]) { @@ -44,6 +44,7 @@ class CatalogAdapter extends ScriptedAdapter { constructor( private readonly provider: LlmProviderInfo, private readonly models: readonly LlmModelInfo[], + private readonly contexts: Readonly> = {}, ) { super(SCRIPT) } @@ -55,6 +56,13 @@ class CatalogAdapter extends ScriptedAdapter { override listModels(_provider: string): Promise { return Promise.resolve(this.models) } + + override resolveModelContext( + _provider: string, + model: string, + ): Promise { + return Promise.resolve(this.contexts[model]) + } } const SCRIPT: StreamChunk[] = [ @@ -439,8 +447,42 @@ describe('LlmService', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'plain', name: 'plain' }]) await expect(ctx.llm.listModels('plain')).resolves.toEqual([]) await expect(ctx.llm.listModels('missing')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(ctx.llm.resolveModelContext('plain', 'unlisted')).resolves.toBeUndefined() + await expect(ctx.llm.resolveModelContext('missing', 'm')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) }) + it('resolves detached model context independently of advisory catalog membership', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const source = { contextWindow: 32_000 } + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + { unlisted: source }, + )) + + const resolved = await ctx.llm.resolveModelContext('route', 'unlisted') + expect(resolved).toEqual({ contextWindow: 32_000 }) + source.contextWindow = 64_000 + expect(resolved).toEqual({ contextWindow: 32_000 }) + await expect(ctx.llm.resolveModelContext('route', 'other')).resolves.toBeUndefined() + }) + + it.each([0, -1, 1.5, Number.NaN])( + 'rejects invalid adapter model context %s', + async (contextWindow) => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + { model: { contextWindow } }, + )) + await expect(ctx.llm.resolveModelContext('route', 'model')) + .rejects.toMatchObject({ code: 'INVALID_MODEL_CONTEXT' }) + }, + ) + it.each([ [{ id: 1, name: 'Name' }, 'non-string id'], [{ id: 'other', name: 'Name' }, 'mismatched id'], diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 20539de6a4..18f828ddd4 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -4,11 +4,7 @@ Replay-aware token measurement through the singleton `ctx.tokenMeter` service. I ## Configuration -| Key | Default | Contract | -|---|---:|---| -| `contextWindow` | `128000` | Positive integer service-wide context capacity. | - -The estimator intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. `contextWindow` is the only deployment setting. Direct construction validates it; Loader mounts first apply the package's Schemastery shape validation. Unrecognized top-level keys are rejected. +The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelContext()`. ## Measurement contract @@ -30,13 +26,7 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket - name: '@deepseek-ai/dsh-compact-basic' ``` -Both plugins have usable defaults. A deployment with a different capacity configures the meter once: - -```yaml -- name: '@deepseek-ai/dsh-token-meter' - config: - contextWindow: 32768 -``` +Both plugins have usable defaults. The meter remains independent of model routing and optional compaction. A deployment configures capacity on its LLM adapter and compaction policy on `dsh-compact-basic`. ## Model Experience diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 0c18dee74b..2fdfb064db 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -19,12 +19,6 @@ import type { export type * from './types.ts' -/** Default service-wide provider context capacity. */ -const DEFAULT_CONTEXT_WINDOW = 128_000 - -/** Complete public configuration key set. */ -const TOKEN_METER_CONFIG_KEYS: ReadonlySet = new Set(['contextWindow']) - /** Fixed text-density estimate used until exact tokenization is needed. */ const CHARS_PER_TOKEN = 4 @@ -74,28 +68,10 @@ function optionalHeaderEquals( /** Reject stale or misspelled keys before defaults can hide them. */ function validateConfigKeys(config: TokenMeterConfig): void { for (const key of Object.keys(config)) { - if (!TOKEN_METER_CONFIG_KEYS.has(key)) { - throw new Error( - `TokenMeterConfig: unknown key "${key}" (allowed: contextWindow)`, - ) - } + throw new Error(`TokenMeterConfig: unknown key "${key}" (no settings are supported)`) } } -/** Resolve and validate the one service-wide context capacity. */ -function resolveContextWindow(config: TokenMeterConfig): number { - validateConfigKeys(config) - const contextWindow = config.contextWindow === undefined - ? DEFAULT_CONTEXT_WINDOW - : config.contextWindow - if (!Number.isInteger(contextWindow) || contextWindow <= 0) { - throw new Error( - `TokenMeterConfig: contextWindow (${contextWindow}) must be a positive integer`, - ) - } - return contextWindow -} - declare module 'cordis' { interface Context { tokenMeter: TokenMeterService @@ -104,18 +80,13 @@ declare module 'cordis' { /** Replay owner for one service-wide estimator and isolated per-session folds. */ export class TokenMeterService extends Service { - static Config: z = z.object({ - contextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), - }) - - /** Provider context-window capacity used by pressure consumers. */ - readonly contextWindow: number + static Config: z = z.object({}) private readonly states = new WeakMap() constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') - this.contextWindow = resolveContextWindow(config) + validateConfigKeys(config) // Readers catch up independently, while eager observation bounds ordinary // read latency without creating state for sessions no consumer has read. diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index 7fb35af997..33aa3d4bb5 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -6,11 +6,8 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm' -/** Token-meter plugin configuration. */ -export interface TokenMeterConfig { - /** Service-wide context-window capacity in tokens. Defaults to `128000`. */ - contextWindow?: number -} +/** Token-meter plugin configuration; the fixed estimator has no settings. */ +export type TokenMeterConfig = object /** The baseline from which a signed surface delta produces current pressure. */ export type TokenMeasurementBaseline = diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e7ddca6696..dc7d0c6e2e 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -87,29 +87,13 @@ function expectSurfaceTotal(measurement: TokenMeasurement): void { } describe('TokenMeterService configuration and registration', () => { - it('provides one zero-config context window', () => { - const service = meter() - expect(service.contextWindow).toBe(128_000) - }) - - it('accepts one service-wide context-window override', () => { - expect(meter({ contextWindow: 32_000 }).contextWindow).toBe(32_000) - }) - - it.each(['models', 'contextWidow'])('rejects unknown top-level config key %s', (key) => { - expect(() => meter({ [key]: {} })) - .toThrow(`TokenMeterConfig: unknown key "${key}"`) - }) - - it.each([ - { contextWindow: 0 }, - { contextWindow: -1 }, - { contextWindow: 1.5 }, - { contextWindow: Number.NaN }, - { contextWindow: null }, - ] as unknown as TokenMeterConfig[])('rejects invalid context capacity %#', (config) => { - expect(() => meter(config)).toThrow(/contextWindow .* positive integer/) - }) + it.each(['models', 'contextWindow', 'contextWidow'])( + 'rejects stale or unknown top-level config key %s', + (key) => { + expect(() => meter({ [key]: {} })) + .toThrow(`TokenMeterConfig: unknown key "${key}"`) + }, + ) it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => { const ctx = new Context() @@ -123,7 +107,7 @@ describe('TokenMeterService configuration and registration', () => { describe('TokenMeterService pricing', () => { it('prices every built-in content shape and merge-extended blocks with one fixed heuristic', () => { - const service = meter({ contextWindow: 100 }) + const service = meter() const blocks: ContentBlock[] = [ { type: 'text', text: 'abcd' }, { type: 'reasoning', text: 'ab' }, @@ -331,7 +315,7 @@ describe('replay anchors and surface folds', () => { }) it('keeps only the latest successful request anchor across model switches', () => { - const service = meter({ contextWindow: 1_000 }) + const service = meter() const session = new Session(SessionId('switch')) const alphaHeader = header('alpha', { system: 'same envelope' }) appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 15780b17e0..1506d97104 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -34,6 +34,7 @@ export const LINK_MAP: Record = { ContinuationStop: 'core.md', GenerateOptions: 'core.md', LlmCallConfig: 'core.md', + LlmModelContext: 'core.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', Message: 'core.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 202f2efb6e..a90c76c577 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -9,6 +9,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelInfo", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelContext", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 9cad9215fb..8a9142381d 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -544,7 +544,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t - `options` — the full request. A LOOP-built request arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability Agent Note), so listeners read it, never rewrite it. A hand-built one-shot (compaction summarize) is the caller's own object and stays mutable here. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L43) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L50) ## session/* diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index e8e4d53a16..da8f2f4c33 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -6,7 +6,7 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L97) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L118) ### ctx.llm.registerAdapter(providers, adapter) @@ -29,7 +29,7 @@ Register an adapter for the given provider routes. Throws `LlmError` with code ` **Returns** the disposer that unregisters all of them. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L112) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L133) ### ctx.llm.listProviders() @@ -45,7 +45,7 @@ Describe provider routes with a registered adapter. **Returns** detached provider metadata in registration order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L143) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L164) ### ctx.llm.listModels(provider) @@ -65,7 +65,30 @@ Discover models advertised by one registered provider. Catalog membership is adv **Returns** detached model metadata in adapter-preferred order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L153) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L174) + +### ctx.llm.resolveModelContext(provider, model) + +```ts website-api +/** + * Resolve context capacity from the adapter that owns one exact route. + * This query is independent of the advisory model catalog: an unlisted model + * may return metadata, while `undefined` never rejects later routing. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached context metadata, or `undefined` when the adapter has none. + */ +async resolveModelContext( provider: string, model: string, ): Promise +``` + +Resolve context capacity from the adapter that owns one exact route. This query is independent of the advisory model catalog: an unlisted model may return metadata, while `undefined` never rejects later routing. + +- `provider` — registered provider route to inspect. +- `model` — exact model id passed to the adapter. + +**Returns** detached context metadata, or `undefined` when the adapter has none. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L209) ### ctx.llm.stream(options) @@ -91,4 +114,4 @@ Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with **Returns** the chunk stream, possibly wrapped by `llm/stream` listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L308) diff --git a/website/zh-CN/api/harness/token-meter.md b/website/zh-CN/api/harness/token-meter.md index 30b83f79d4..26ffb24b1a 100644 --- a/website/zh-CN/api/harness/token-meter.md +++ b/website/zh-CN/api/harness/token-meter.md @@ -6,18 +6,7 @@ Replay owner for one service-wide estimator and isolated per-session folds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L106) - -### ctx.tokenMeter.contextWindow - -```ts website-api -/** Provider context-window capacity used by pressure consumers. */ -readonly contextWindow: number -``` - -Provider context-window capacity used by pressure consumers. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L112) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L82) ### ctx.tokenMeter.measure(session, requestHeader?) @@ -50,7 +39,7 @@ Provider usage is reused only when the latest successful call's canonical reques **Returns** a detached deeply immutable pressure and surface measurement. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L143) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L114) ### ctx.tokenMeter.estimateMessage(message) @@ -69,4 +58,4 @@ Heuristically price one model-visible message. **Returns** content and role-framing tokens under the fixed service heuristic. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L181) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L152) diff --git a/website/zh-CN/guide/config.md b/website/zh-CN/guide/config.md index 35fb42185b..9c5acb415e 100644 --- a/website/zh-CN/guide/config.md +++ b/website/zh-CN/guide/config.md @@ -52,15 +52,17 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 # LLM 后端:从 npm 包加载,具备接入 DeepSeek API 能力 # `!!js` 从环境变量读取密钥,不会写进配置文件 -# `models` 声明该适配器能处理哪些模型名 +# `models` 提供可选模型目录,并为精确模型声明上下文容量 - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - - deepseek-v4-pro - - deepseek-v4-flash + - id: deepseek-v4-pro + contextWindow: 128000 + - id: deepseek-v4-flash + contextWindow: 128000 # Bash 执行器:让 Agent 能跑 shell 命令 # timeoutMs 设置单条命令的超时时间 @@ -84,11 +86,9 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 You are a coding agent powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. -# Token 计量:统一定义模型能看到的 token 上限 +# Token 计量:从持久日志计算请求压力;模型容量由上面的适配器提供 - id: token-meter name: '@deepseek-ai/dsh-token-meter' - config: - contextWindow: 128000 # 自动压缩:对话太长时自动总结旧内容,腾出上下文空间 # thresholdRatio 超过这个比例就触发压缩 @@ -97,7 +97,7 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 name: '@deepseek-ai/dsh-compact-basic' config: thresholdRatio: 0.8 - retainTokens: 20480 + retainRatio: 0.16 maxTokens: 8192 compactionRetries: 1 @@ -243,7 +243,7 @@ config: |------|------|--------|------| | `apiKey` | string | `$DEEPSEEK_API_KEY` | API 密钥。省略则从环境变量读取 | | `baseURL` | string | `$DEEPSEEK_BASE_URL` 或官方地址 | API 端点 | -| `models` | string[] | `['deepseek-v4-flash', 'deepseek-v4-pro']` | 注册的模型名列表 | +| `models` | object[] | V4 Flash 与 V4 Pro(各 `128000` token) | 建议模型目录;每项包含 `id`,可选 `name`、`description` 与 `contextWindow` | | `thinking` | `'enabled'` \| `'disabled'` | `'enabled'` | 是否开启思维链 | | `reasoningEffort` | `'high'` \| `'max'` | — | 思维链深度(仅 thinking 开启时有效) | @@ -265,14 +265,16 @@ config: | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| -| `contextWindow` | number | **必填** | 模型的上下文窗口大小(token) | -| `thresholdRatio` | number | **必填** | token 占用超过此比例时触发压缩(0-1) | -| `retainTokens` | number | **必填** | 压缩后至少保留多少 token 的近期内容 | -| `maxTokens` | number | **必填** | 总结时的最大输出 token | +| `thresholdRatio` | number | `0.8` | token 占用超过路由模型容量的此比例时触发压缩(0-1) | +| `retainRatio` | number | `0.16` | 压缩后按路由模型容量比例保留近期内容;与 `retainTokens` 互斥 | +| `retainTokens` | number | — | 按绝对 token 数保留近期内容;与 `retainRatio` 互斥 | +| `summarizationProvider` | string | `''`(使用当前提供方) | 专门用于总结的提供方;与 `summarizationModel` 同时设置 | | `summarizationModel` | string | `''`(用当前模型) | 专门用于总结的模型名 | -| `compactionRetries` | number | **必填** | 首次压缩后仍超标时的额外重试次数 | -| `auto` | boolean | `true` | 是否自动在每步前检查并触发压缩 | -| `charsPerToken` | number | `4` | 每 token 估算字符数。中文应设 1-2 | +| `maxTokens` | number | `8192` | 总结时的最大输出 token | +| `compactionRetries` | number | `1` | 首次压缩后仍超标时的额外重试次数 | +| `maxOverflowRetries` | number | `1` | 提供方确认上下文溢出后的最大恢复重试次数 | +| `modelPolicies` | object[] | `[]` | 按精确 `{ provider, model }` 组合部分覆盖上述策略 | +| `auto` | boolean | `true` | 是否在每个成功步骤后检查压力,并处理规范化上下文溢出 | ### fs-local(文件系统) From 734a45cf66149c474556480ee916747af0d777bb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 18:36:21 +0800 Subject: [PATCH 10/30] ci: deploy documentation to GitHub Pages --- ...026-07-13-documentation-site-projection.md | 6 +- .github/workflows/docs-pages.yml | 84 +++++++++++++++++++ website/.vitepress/config.ts | 1 + 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/docs-pages.yml diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index b3bdbb0c1b..44d1b17a5b 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -20,7 +20,7 @@ The projector parses Markdown links without reserializing the document. A link t Mermaid renders the canonical diagrams. The website workspace explicitly declares the five packages that `vitepress-plugin-mermaid` asks Vite to prebundle because pnpm's strict dependency isolation otherwise makes those transitive packages unavailable to the local development server; Knip records this runtime-only use as an intentional dependency exception. -Site publication is separate from site construction. The repository contains local development and build commands, but no hosting or deployment workflow until a public destination is chosen. +Site publication remains separate from site construction. A dedicated GitHub Actions workflow runs the existing documentation gates, uploads `website/.dist` as a Pages artifact, and deploys only after the build succeeds. `actions/configure-pages` supplies the destination's base path to VitePress at build time, so the private Pages origin, a later public project path, and a custom domain do not require distinct checked-in configurations. Pages visibility remains a repository hosting setting rather than a workflow permission. ## Alternatives considered @@ -34,8 +34,10 @@ Site publication is separate from site construction. The repository contains loc **Build only in a deployment workflow.** A deployment job can reveal rendering failures after merge. Keeping the production build in `doc-sync` makes the same failure visible locally and in ordinary CI even when no public deployment exists. +**Hard-code the public project path.** A fixed `/deepseek-harness/` base works for the public project URL but not for the unique origin assigned to a private Pages site or for a future custom domain. Consuming Pages metadata keeps one build contract across those destinations. + ## Consequences -Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. +Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. Merges that affect the documentation site deploy the checked result to Pages, while manual dispatch provides a recovery and validation entry point. The publication manifest is a maintained allowlist, and link projection adds a small repository-specific build adapter. A new kind of Markdown link behavior needs a projector test. Mermaid support also increases the client bundle size, but preserves diagrams already used by the canonical documentation. diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 0000000000..41e003ae8a --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,84 @@ +name: Deploy documentation + +on: + push: + # Keep this branch only for the pre-merge deployment validation run. + branches: [master, worktree/docs-pages-deploy] + paths: + - '.github/workflows/docs-pages.yml' + - 'docs/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'scripts/project-doc-site.ts' + - 'scripts/project-doc-site.spec.ts' + - 'website/**' + workflow_dispatch: + +concurrency: + group: github-pages + cancel-in-progress: false + +permissions: + contents: read + +env: + PRIMARY_NODE_VERSION: '24' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + pages: read + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Verify and build documentation + env: + DOCS_BASE: ${{ steps.pages.outputs.base_path }}/ + run: pnpm run doc-sync + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: website/.dist + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index b9f38b0f7e..3ea513a0bb 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -108,6 +108,7 @@ const sharedTheme: Pick Date: Mon, 20 Jul 2026 18:39:37 +0800 Subject: [PATCH 11/30] fix(invariants): require justified empty companions --- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 9 ++-- ...7-19-package-owned-invariant-service.zh.md | 9 ++-- packages/AGENTS.md | 2 +- packages/bash/bash-local/src/invariant.ts | 2 +- packages/bash/bash-sandbox/src/invariant.ts | 2 +- packages/bash/bash/src/invariant.ts | 2 +- packages/bash/tool-bash/src/invariant.ts | 2 +- .../code-runtime-worker/src/invariant.ts | 2 +- .../code-runtime/src/invariant.ts | 2 +- .../compact/compact-basic/src/invariant.ts | 2 +- packages/compact/compact/src/invariant.ts | 2 +- .../context/time-context/src/invariant.ts | 2 +- .../workspace-context/src/invariant.ts | 2 +- packages/cordis/tool-cordis/src/invariant.ts | 2 +- packages/core/system-prompt/src/invariant.ts | 2 +- packages/core/tools/src/invariant.ts | 2 +- packages/examples/acp-demo/src/invariant.ts | 2 +- .../agent-spine-demo/src/invariant.ts | 2 +- packages/examples/cli-demo/src/invariant.ts | 2 +- .../examples/jsonrpc-demo/src/invariant.ts | 2 +- packages/examples/stdio-demo/src/invariant.ts | 2 +- packages/fs/fs-local/src/invariant.ts | 2 +- packages/fs/fs-policy/src/invariant.ts | 2 +- packages/fs/fs/src/invariant.ts | 2 +- packages/fs/tool-fs-search/src/invariant.ts | 2 +- packages/fs/tool-fs/src/invariant.ts | 2 +- .../guard/repeat-tool-guard/src/invariant.ts | 2 +- packages/hooks/hook-protocol/src/invariant.ts | 2 +- packages/hooks/hooks-claude/src/invariant.ts | 2 +- packages/hooks/hooks-codex/src/invariant.ts | 2 +- packages/llm/llm-deepseek/src/invariant.ts | 2 +- packages/llm/llm-pi-ai/src/invariant.ts | 2 +- packages/llm/llm/src/invariant.ts | 2 +- packages/llm/token-meter/src/invariant.ts | 2 +- packages/mcp/mcp-client/src/invariant.ts | 2 +- .../sandbox/sandbox-local/src/invariant.ts | 2 +- packages/sandbox/sandbox/src/invariant.ts | 2 +- packages/sdk/create-sdk/src/invariant.ts | 2 +- packages/sdk/helper/src/invariant.ts | 2 +- packages/sdk/scripts/src/invariant.ts | 2 +- packages/sdk/telemetry/src/invariant.ts | 2 +- .../src/invariant.ts | 2 +- .../src/invariant.ts | 2 +- .../session-persistence/src/invariant.ts | 2 +- .../session-query/src/invariant.ts | 2 +- packages/skill/skill-local/src/invariant.ts | 2 +- packages/skill/skill/src/invariant.ts | 2 +- packages/skill/tool-skill/src/invariant.ts | 2 +- packages/spill/spill-local/src/invariant.ts | 2 +- packages/spill/spill-policy/src/invariant.ts | 2 +- packages/spill/spill/src/invariant.ts | 2 +- .../subagent/subagent-acp/src/invariant.ts | 2 +- .../subagent/subagent-fork/src/invariant.ts | 2 +- .../subagent-inprocess/src/invariant.ts | 2 +- .../subagent/subagent-spawn/src/invariant.ts | 2 +- .../subagent-subprocess/src/invariant.ts | 2 +- packages/subagent/subagent/src/invariant.ts | 2 +- .../subagent/tool-subagent/src/invariant.ts | 2 +- .../support/acp-snapshot/src/invariant.ts | 2 +- .../agent-loop-testkit/src/invariant.ts | 2 +- packages/support/invariants/src/invariant.ts | 2 +- packages/support/llm-replay/src/invariant.ts | 2 +- .../support/loader-smoke/src/invariant.ts | 2 +- packages/tasks/tasks/src/invariant.ts | 2 +- packages/tasks/tool-tasks/src/invariant.ts | 2 +- .../timeout/timeout-policy/src/invariant.ts | 2 +- packages/todo/tool-todo/src/invariant.ts | 2 +- packages/ui/acp/src/invariant.ts | 2 +- packages/ui/app-boot/src/invariant.ts | 2 +- packages/ui/jsonrpc/src/invariant.ts | 2 +- packages/ui/permission/src/invariant.ts | 2 +- packages/ui/stdio/src/invariant.ts | 2 +- packages/ui/tool-ask-user/src/invariant.ts | 2 +- packages/ui/tui/src/invariant.ts | 2 +- packages/ui/user-approval/src/invariant.ts | 2 +- packages/ui/user-interaction/src/invariant.ts | 2 +- packages/util/brand/src/invariant.ts | 2 +- packages/util/home/src/invariant.ts | 2 +- packages/util/paths/src/invariant.ts | 2 +- packages/util/retention/src/invariant.ts | 2 +- packages/util/timeout/src/invariant.ts | 2 +- packages/web/tool-web/src/invariant.ts | 2 +- packages/web/web-fetch-local/src/invariant.ts | 2 +- .../web/web-search-deepseek/src/invariant.ts | 2 +- packages/web/web-search-exa/src/invariant.ts | 2 +- .../web-search-perplexity/src/invariant.ts | 2 +- packages/web/web/src/invariant.ts | 2 +- .../workflow/tool-workflow/src/invariant.ts | 2 +- .../workflow-workerthread/src/invariant.ts | 2 +- packages/workflow/workflow/src/invariant.ts | 2 +- scripts/package-invariants.spec.ts | 13 +++++ scripts/package-invariants.ts | 50 ++++++++++++++++++- 93 files changed, 162 insertions(+), 99 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index ef4090b3d6..4665f0ede8 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: d7b77439c8cfdfdf2fc4f01e779a6d3c7f345457 -2026-07-19-package-owned-invariant-service.zh.md: b553d137f9f06b57f24672bf6984d087ca99ceb2 +2026-07-19-package-owned-invariant-service.md: 19512580db7b228fbf4c44109ccb07eeaca80b54 +2026-07-19-package-owned-invariant-service.zh.md: f7c4bc4352e3ab1cd3a5aa7d2d0a64734bc22cb2 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index d7b77439c8..19512580db 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -18,7 +18,7 @@ Package ownership must also be exhaustive. Without a mechanical repository rule, `@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. -Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A package with no relational check uses a generated ownership-only installer: it reserves the name through the real service boundary but installs no listeners. Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. +Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. Checks protect observable relations in event streams or mutable runtime data; service method presence and plugin wiring are type, load, and repository-gate contracts rather than runtime invariants. A package with no plausible runtime relation uses an empty installer whose `No runtime invariant:` comment explains the absence instead of inventing a synthetic assertion. Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. ### Configuration and selection @@ -64,9 +64,9 @@ The former functional-plugin entrypoint and one-argument `InvariantError` constr | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -These four owners contain stateful checks and focused tests. Every other package carries a generated baseline companion until it gains a relational assertion. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. +These four owners contain stateful checks and focused tests. Every other package carries a generated baseline companion until it gains a relational assertion or records why no runtime relation exists. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. -`verify-package-invariants` discovers every workspace package and rejects missing or stale companion source, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. The generator writes only missing or marked ownership baselines, so a package-owned implementation is never replaced. +`verify-package-invariants` discovers every workspace package and rejects missing or stale companion source, foreign or unresolved registration names, unexplained empty installers, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. The generator writes only missing or marked ownership baselines, so a package-owned implementation is never replaced. ### Scoped-event semantic map @@ -91,12 +91,13 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s - **Keep all checks in `dsh-invariants`.** Rejected because the registry would continue importing every checked product domain, owner changes would require central edits, and package tests would remain detached from the contracts they protect. - **Let root package entrypoints register checks implicitly when `ctx.invariants` happens to exist.** Rejected because root behavior would depend on composition order and optional service presence, diagnostics could not be selected independently, and package loading would hide a registration effect outside an explicit companion. - **Discover every `invariant.ts` file automatically at runtime.** Rejected because filesystem/package discovery is not a runtime ownership contract, makes bundled publication ambiguous, and cannot express explicit Cordis load order or dependency installation. Build-time generation, verification, and the test host may enumerate the source tree because they validate repository completeness rather than composing a shipped deployment. +- **Require a synthetic assertion from every package.** Rejected because checking method presence, plugin names, or fixed examples only turns repository wiring and unit-test facts into startup work. Packages without an observable event or mutable-data relation state that fact locally and keep an empty installer. - **Validate allow/block entries against the currently loaded package set.** Rejected because a zero-match pattern can intentionally target a later or HMR-loaded contribution; current load order must not determine config validity. ## Consequences - Product packages own and test their relational assertions while the service stays product-independent. -- Every package pays the small publication and dependency cost of an invariant companion, including packages whose generated baseline currently installs no listeners. +- Every package pays the small publication and dependency cost of an invariant companion, including packages whose justified empty installer has no listener. - Standard compositions can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index b553d137f9..f7c4bc4352 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -18,7 +18,7 @@ Status: implemented `@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 -工作区内的每个包都发布 `./invariant` 伴随插件,并注册自己完整且准确的 npm 包名。没有关系检查的包使用生成的仅声明所有权 installer:它通过真实服务边界占用包名,但不安装监听器。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 +工作区内的每个包都发布 `./invariant` 伴随插件,并注册自己完整且准确的 npm 包名。检查保护事件流或可变运行时数据中可观察的关系;服务方法是否存在以及插件接线是否正确,属于类型、加载和仓库门禁契约,不是运行时不变式。没有合理运行时关系可检查的包使用空 installer,并通过 `No runtime invariant:` 注释解释原因,而不是编造断言。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 ### 配置与选择 @@ -64,9 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -这四个所有者保存有状态检查与聚焦测试。其他每个包在获得关系断言之前,都带有生成的基线伴随插件。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 +这四个所有者保存有状态检查与聚焦测试。其他每个包在获得关系断言或记录没有运行时关系的原因之前,都带有生成的基线伴随插件。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 -`verify-package-invariants` 会发现每个工作区包,并拒绝缺失或陈旧的伴随插件源码、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。生成器只写入缺失或带生成标记的所有权基线,因此绝不会替换包自行维护的实现。 +`verify-package-invariants` 会发现每个工作区包,并拒绝缺失或陈旧的伴随插件源码、外部或无法解析的注册名、没有解释的空 installer、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。生成器只写入缺失或带生成标记的所有权基线,因此绝不会替换包自行维护的实现。 ### Scoped event 语义映射 @@ -91,12 +91,13 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 - **把所有检查保留在 `dsh-invariants`。** 不予采纳,因为注册包仍要导入所有被检查的产品领域,所有者变更仍需中央编辑,测试也继续远离被保护的契约。 - **当 `ctx.invariants` 恰好存在时,让根包入口隐式注册检查。** 不予采纳,因为根入口行为会依赖组合顺序与可选服务是否存在,诊断无法独立选择,而且包加载会隐藏一个不在显式伴随插件中的注册 effect。 - **在运行时自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。构建期生成与校验以及测试 host 可以枚举源码树,因为它们验证的是仓库完整性,而不是组合已发布的部署。 +- **要求每个包提供合成断言。** 不予采纳,因为检查方法是否存在、插件名或固定样例,只会把仓库接线与单元测试事实变成启动时工作。没有可观察事件或可变数据关系的包会在本地说明原因,并保留空 installer。 - **根据当前已加载包集合验证 allow/block 条目。** 不予采纳,因为零匹配模式可能有意指向稍后加载或 HMR 加载的贡献;当前加载顺序不能决定配置有效性。 ## 后果 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 -- 每个包都要承担不变式伴随插件带来的少量发布与依赖成本,包括目前只安装生成基线、不添加监听器的包。 +- 每个包都要承担不变式伴随插件带来的少量发布与依赖成本,包括使用有理由空 installer、不添加监听器的包。 - 标准组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 diff --git a/packages/AGENTS.md b/packages/AGENTS.md index adf1ba1cd0..f1403bb973 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,7 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns an invariant companion.** Publish `./invariant`, register its manifest name, and retain the generated baseline until relational checks exist. `verify-package-invariants` gates source and publication wiring ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). +- **Every package owns an invariant companion.** Publish `./invariant` and register its manifest name. Check observable event or mutable-data relations; when none exists, keep an empty installer with a `No runtime invariant:` comment explaining why instead of inventing a shape or presence assertion. `verify-package-invariants` gates source and publication wiring ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). Naming notes: diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts index 8af5f91b22..0fe56f5929 100644 --- a/packages/bash/bash-local/src/invariant.ts +++ b/packages/bash/bash-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'bash-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts index e74190fa82..26e0bb769e 100644 --- a/packages/bash/bash-sandbox/src/invariant.ts +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'bash-sandbox-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts index 350f68bfc7..9f9d4cf164 100644 --- a/packages/bash/bash/src/invariant.ts +++ b/packages/bash/bash/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'bash-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts index 286089c7ff..65c124801d 100644 --- a/packages/bash/tool-bash/src/invariant.ts +++ b/packages/bash/tool-bash/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-bash-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts index 41b3eab511..443fa67a1d 100644 --- a/packages/code-runtime/code-runtime-worker/src/invariant.ts +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'code-runtime-worker-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts index 6102927d77..1e5b5e4bb9 100644 --- a/packages/code-runtime/code-runtime/src/invariant.ts +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'code-runtime-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts index f72c0e5898..5afc935223 100644 --- a/packages/compact/compact-basic/src/invariant.ts +++ b/packages/compact/compact-basic/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'compact-basic-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index f7a2cbffd1..40547461dc 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'compact-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 64fb98ac81..c452699b59 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'time-context-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index f3e76225c0..6a49e1bd15 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'workspace-context-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/cordis/tool-cordis/src/invariant.ts b/packages/cordis/tool-cordis/src/invariant.ts index a3f375b69f..194ac33b1c 100644 --- a/packages/cordis/tool-cordis/src/invariant.ts +++ b/packages/cordis/tool-cordis/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-cordis-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts index 5117f93ce0..d281d29f2b 100644 --- a/packages/core/system-prompt/src/invariant.ts +++ b/packages/core/system-prompt/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'system-prompt-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 7c5743e1d4..a296415939 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tools-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts index d8f2ff17dc..4050fa90d7 100644 --- a/packages/examples/acp-demo/src/invariant.ts +++ b/packages/examples/acp-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'acp-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts index 913b5ca2ab..134c5d7319 100644 --- a/packages/examples/agent-spine-demo/src/invariant.ts +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'agent-spine-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/cli-demo/src/invariant.ts b/packages/examples/cli-demo/src/invariant.ts index 5681362dcc..35077586c4 100644 --- a/packages/examples/cli-demo/src/invariant.ts +++ b/packages/examples/cli-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'cli-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index 21f19faf0f..b59fcfd9ba 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'jsonrpc-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/examples/stdio-demo/src/invariant.ts b/packages/examples/stdio-demo/src/invariant.ts index 5d51c3cd9b..93d5a73df7 100644 --- a/packages/examples/stdio-demo/src/invariant.ts +++ b/packages/examples/stdio-demo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'stdio-demo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts index 0c89f2299b..ed93f88c72 100644 --- a/packages/fs/fs-local/src/invariant.ts +++ b/packages/fs/fs-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'fs-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts index 11f155d2ec..60b40e074d 100644 --- a/packages/fs/fs-policy/src/invariant.ts +++ b/packages/fs/fs-policy/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'fs-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts index 55895b4b24..79dcb9b3ee 100644 --- a/packages/fs/fs/src/invariant.ts +++ b/packages/fs/fs/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'fs-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts index f0055b611a..b010f15d6c 100644 --- a/packages/fs/tool-fs-search/src/invariant.ts +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-fs-search-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts index 7a683d978f..ecdf7237ee 100644 --- a/packages/fs/tool-fs/src/invariant.ts +++ b/packages/fs/tool-fs/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-fs-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts index 05c4df1a66..1bf0818b72 100644 --- a/packages/guard/repeat-tool-guard/src/invariant.ts +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'repeat-tool-guard-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 9893493644..c2dac9910d 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'hook-protocol-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts index 5c6002f7f5..f712873f5f 100644 --- a/packages/hooks/hooks-claude/src/invariant.ts +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'hooks-claude-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts index 1b8f03a057..e81eb33340 100644 --- a/packages/hooks/hooks-codex/src/invariant.ts +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'hooks-codex-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts index c3e6b5e153..8a00b0b498 100644 --- a/packages/llm/llm-deepseek/src/invariant.ts +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'llm-deepseek-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts index 4ada7a1a51..a49ebab07e 100644 --- a/packages/llm/llm-pi-ai/src/invariant.ts +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'llm-pi-ai-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index a8a56ce245..3911551916 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'llm-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index 00ceb567be..8fd25f6998 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'token-meter-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts index d9e75e9955..92e58ec0e4 100644 --- a/packages/mcp/mcp-client/src/invariant.ts +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'mcp-client-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts index 3582962f94..e16cc06ead 100644 --- a/packages/sandbox/sandbox-local/src/invariant.ts +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'sandbox-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts index b220f11717..ffe9cb9db5 100644 --- a/packages/sandbox/sandbox/src/invariant.ts +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'sandbox-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts index 87a697e438..2f995b9029 100644 --- a/packages/sdk/create-sdk/src/invariant.ts +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'create-sdk-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts index 63a6fc2055..873281d567 100644 --- a/packages/sdk/helper/src/invariant.ts +++ b/packages/sdk/helper/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'helper-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts index fd0a0ef55b..7a637eecab 100644 --- a/packages/sdk/scripts/src/invariant.ts +++ b/packages/sdk/scripts/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'scripts-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/sdk/telemetry/src/invariant.ts b/packages/sdk/telemetry/src/invariant.ts index 945b9e50e7..8af7848563 100644 --- a/packages/sdk/telemetry/src/invariant.ts +++ b/packages/sdk/telemetry/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'telemetry-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts index 12c65db1c4..d7e8c4bb07 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'session-persistence-jsonl-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts index a9b04cf5f5..c995336c2b 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'session-persistence-sqlite-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-persistence/session-persistence/src/invariant.ts b/packages/session-persistence/session-persistence/src/invariant.ts index cc7cc8fa2c..bbbe4cb523 100644 --- a/packages/session-persistence/session-persistence/src/invariant.ts +++ b/packages/session-persistence/session-persistence/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'session-persistence-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts index 91bcee721e..d65f690cbc 100644 --- a/packages/session-query/session-query/src/invariant.ts +++ b/packages/session-query/session-query/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'session-query-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/skill/skill-local/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts index 475d02bb8f..1230edb9a1 100644 --- a/packages/skill/skill-local/src/invariant.ts +++ b/packages/skill/skill-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'skill-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts index c1d4091bb2..43abd10c43 100644 --- a/packages/skill/skill/src/invariant.ts +++ b/packages/skill/skill/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'skill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts index abf4ba3961..97a5e04558 100644 --- a/packages/skill/tool-skill/src/invariant.ts +++ b/packages/skill/tool-skill/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-skill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts index d638ffa2a9..41b4c82d1c 100644 --- a/packages/spill/spill-local/src/invariant.ts +++ b/packages/spill/spill-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'spill-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts index d4aa544ecd..aeae1b4583 100644 --- a/packages/spill/spill-policy/src/invariant.ts +++ b/packages/spill/spill-policy/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'spill-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts index 714e43f3a6..0b23ef8633 100644 --- a/packages/spill/spill/src/invariant.ts +++ b/packages/spill/spill/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'spill-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts index a5828fd4c1..5cda5764a3 100644 --- a/packages/subagent/subagent-acp/src/invariant.ts +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-acp-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts index c6903fd82d..4a40e34fa4 100644 --- a/packages/subagent/subagent-fork/src/invariant.ts +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-fork-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index 0eac204104..5ece1a32c3 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-inprocess-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts index d179a2b72a..913b2e136c 100644 --- a/packages/subagent/subagent-spawn/src/invariant.ts +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-spawn-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index 22dac32814..ee0047854f 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-subprocess-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index 3a79592ee1..ab491bfb4d 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'subagent-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 08881e4b2b..9b38442897 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-subagent-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index 3579b96cf5..02a7cf0b68 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'acp-snapshot-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index fc2554aa77..f299eb08cd 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'agent-loop-testkit-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts index b6b4d8ae48..d55b2b73b1 100644 --- a/packages/support/invariants/src/invariant.ts +++ b/packages/support/invariants/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'invariants-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts index 50295cbedf..c6349a23b0 100644 --- a/packages/support/llm-replay/src/invariant.ts +++ b/packages/support/llm-replay/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'llm-replay-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 9265a5f8b4..6728f4ab5b 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'loader-smoke-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts index 468fe664b9..afc5901d92 100644 --- a/packages/tasks/tasks/src/invariant.ts +++ b/packages/tasks/tasks/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tasks-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts index fded38c895..dbb01600be 100644 --- a/packages/tasks/tool-tasks/src/invariant.ts +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-tasks-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/timeout/timeout-policy/src/invariant.ts b/packages/timeout/timeout-policy/src/invariant.ts index 9e7b5b7d4b..4a6933e8f8 100644 --- a/packages/timeout/timeout-policy/src/invariant.ts +++ b/packages/timeout/timeout-policy/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'timeout-policy-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index a5980342f3..f925a1ad5b 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-todo-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/acp/src/invariant.ts b/packages/ui/acp/src/invariant.ts index 2c081fc48c..e6ccda5342 100644 --- a/packages/ui/acp/src/invariant.ts +++ b/packages/ui/acp/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'acp-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts index 498d967799..de383a5eb0 100644 --- a/packages/ui/app-boot/src/invariant.ts +++ b/packages/ui/app-boot/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'app-boot-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/jsonrpc/src/invariant.ts b/packages/ui/jsonrpc/src/invariant.ts index 552c312481..f3ef43b461 100644 --- a/packages/ui/jsonrpc/src/invariant.ts +++ b/packages/ui/jsonrpc/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'jsonrpc-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts index 1a774e2e4c..1c9129c916 100644 --- a/packages/ui/permission/src/invariant.ts +++ b/packages/ui/permission/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'permission-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts index 443440a215..37176f11c6 100644 --- a/packages/ui/stdio/src/invariant.ts +++ b/packages/ui/stdio/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'stdio-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/tool-ask-user/src/invariant.ts b/packages/ui/tool-ask-user/src/invariant.ts index eebb1ced42..44c57bae86 100644 --- a/packages/ui/tool-ask-user/src/invariant.ts +++ b/packages/ui/tool-ask-user/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-ask-user-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/tui/src/invariant.ts b/packages/ui/tui/src/invariant.ts index cba5fd9d2e..1f89affab4 100644 --- a/packages/ui/tui/src/invariant.ts +++ b/packages/ui/tui/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tui-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 73dee4f9f1..b1b90d5509 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'user-approval-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/ui/user-interaction/src/invariant.ts b/packages/ui/user-interaction/src/invariant.ts index 262681fc06..bbf1b18041 100644 --- a/packages/ui/user-interaction/src/invariant.ts +++ b/packages/ui/user-interaction/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'user-interaction-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index e932e7e35e..b10caf9080 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'brand-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts index 5874a57c1c..f72c8ec6ae 100644 --- a/packages/util/home/src/invariant.ts +++ b/packages/util/home/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'home-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index c2cedfbb0d..6424d64726 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'paths-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index 7516f9e5ed..2d43081596 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'retention-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index 15140bb880..0a0ddb50c2 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'timeout-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts index 008fe2f5e1..f790931b3f 100644 --- a/packages/web/tool-web/src/invariant.ts +++ b/packages/web/tool-web/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-web-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts index ef61e2a611..f7dc4579fb 100644 --- a/packages/web/web-fetch-local/src/invariant.ts +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-fetch-local-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts index 8781949be9..ac2b4e3a85 100644 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-search-deepseek-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts index a2dd956625..e66d9f6847 100644 --- a/packages/web/web-search-exa/src/invariant.ts +++ b/packages/web/web-search-exa/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-search-exa-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts index fe82c79dae..a4d370d677 100644 --- a/packages/web/web-search-perplexity/src/invariant.ts +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-search-perplexity-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts index b9b1b0d45d..af5862ab5f 100644 --- a/packages/web/web/src/invariant.ts +++ b/packages/web/web/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'web-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index cd1f0e475b..e3f46f0a73 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'tool-workflow-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts index 6bcc40862c..ac47988dcc 100644 --- a/packages/workflow/workflow-workerthread/src/invariant.ts +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'workflow-workerthread-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts index b552021ca4..19e5c9e3c4 100644 --- a/packages/workflow/workflow/src/invariant.ts +++ b/packages/workflow/workflow/src/invariant.ts @@ -17,7 +17,7 @@ export const name = 'workflow-invariant' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 698969b3d7..57395cbf82 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -101,4 +101,17 @@ export const apply = (ctx: { invariants: { register(name: string, install: () => expect(collectPackageInvariantViolations(root).map(violation => violation.message)) .toContain('generated baseline is stale; run pnpm run gen-package-invariants') }) + + it('rejects an unexplained empty package installer', () => { + const source = ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const PACKAGE_NAME = '@deepseek-ai/dsh-probe' +const install = () => {} +export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => + ctx.invariants.register(PACKAGE_NAME, install) +` + expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message)) + .toContain('empty install function must explain why with a "No runtime invariant:" comment') + }) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 45fb64829f..0078a6a4c4 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -11,6 +11,9 @@ import ts from 'typescript' /** Marker identifying baseline companions owned by this generator. */ export const GENERATED_INVARIANT_MARKER = '@generated scripts/gen-package-invariants.ts' +/** Required explanation marker for an intentionally empty installer. */ +export const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' + interface PackageManifest { name?: string exports?: Record @@ -78,7 +81,7 @@ export const name = '${pluginName}' /** Services required before the companion can register. */ export const inject = ['invariants'] -/** Reserve this package's invariant ownership until it adds relational checks. */ +/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ const install: InvariantInstaller = () => {} /** @@ -237,6 +240,51 @@ function checkSource( addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) } } + checkEmptyInstallerReason(owner, sourceFile, sourceText, violations) +} + +function checkEmptyInstallerReason( + owner: PackageInvariantOwner, + sourceFile: ts.SourceFile, + sourceText: string, + violations: PackageInvariantViolation[], +): void { + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) + || declaration.name.text !== 'install' + || declaration.initializer === undefined) continue + const installer = installerFunction(declaration.initializer) + if (installer === undefined + || !ts.isBlock(installer.body) + || installer.body.statements.length > 0) return + const declarationText = sourceText.slice(statement.getFullStart(), statement.getEnd()) + if (!declarationText.includes(NO_RUNTIME_INVARIANT_MARKER)) { + addViolation( + violations, + owner.sourcePath, + `empty install function must explain why with a "${NO_RUNTIME_INVARIANT_MARKER}" comment`, + ) + } + return + } + } +} + +function installerFunction( + initializer: ts.Expression, +): ts.ArrowFunction | ts.FunctionExpression | undefined { + if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer + if (ts.isCallExpression(initializer) + && ts.isPropertyAccessExpression(initializer.expression) + && ts.isIdentifier(initializer.expression.expression) + && initializer.expression.expression.text === 'Object' + && initializer.expression.name.text === 'assign') { + const target = initializer.arguments[0] + if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target + } + return undefined } function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap { From fe393c55c8986b6eb57f5fd70eb2b7fd2fc7a4e7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 18:41:10 +0800 Subject: [PATCH 12/30] ci: restrict Pages deployment to master --- .github/workflows/docs-pages.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 41e003ae8a..e1ad20997d 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -2,8 +2,7 @@ name: Deploy documentation on: push: - # Keep this branch only for the pre-merge deployment validation run. - branches: [master, worktree/docs-pages-deploy] + branches: [master] paths: - '.github/workflows/docs-pages.yml' - 'docs/**' From 1145ee5fc378e1d2158d8d19719d42507558d7bc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:34:19 +0800 Subject: [PATCH 13/30] fix(invariants): assert runtime relationships, not API shapes --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 88 +++--- ...-package-invariant-runtime-contracts.zh.md | 90 +++--- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 14 +- ...7-19-package-owned-invariant-service.zh.md | 14 +- packages/AGENTS.md | 2 +- packages/bash/bash-local/src/invariant.ts | 29 +- packages/bash/bash-sandbox/src/invariant.ts | 31 +- packages/bash/bash/src/invariant.ts | 22 +- packages/bash/bash/tests/invariant.spec.ts | 33 ++ packages/bash/tool-bash/src/invariant.ts | 35 +- .../code-runtime-worker/src/invariant.ts | 26 +- .../code-runtime/src/invariant.ts | 30 +- .../code-runtime/tests/service.spec.ts | 21 -- .../compact/compact-basic/src/invariant.ts | 47 +-- .../compact-basic/tests/compact-basic.spec.ts | 24 +- packages/compact/compact/src/invariant.ts | 100 +++++- .../compact/compact/tests/compact.spec.ts | 4 +- .../compact/compact/tests/invariant.spec.ts | 90 ++++++ .../context/time-context/src/invariant.ts | 62 +++- .../time-context/tests/invariant.spec.ts | 83 +++++ .../workspace-context/src/invariant.ts | 28 +- packages/cordis/tool-cordis/src/invariant.ts | 29 +- packages/core/system-prompt/src/invariant.ts | 49 ++- .../system-prompt/tests/invariant.spec.ts | 44 +++ packages/core/tools/src/invariant.ts | 69 ++-- packages/core/tools/tests/invariant.spec.ts | 84 +++++ packages/examples/acp-demo/src/invariant.ts | 25 +- .../agent-spine-demo/src/invariant.ts | 25 +- packages/examples/cli-demo/src/invariant.ts | 25 +- .../examples/jsonrpc-demo/src/invariant.ts | 20 +- packages/examples/stdio-demo/src/invariant.ts | 25 +- packages/fs/fs-local/src/invariant.ts | 28 +- packages/fs/fs-policy/src/invariant.ts | 27 +- packages/fs/fs/src/invariant.ts | 29 +- packages/fs/fs/tests/invariant.spec.ts | 44 +++ packages/fs/tool-fs-search/src/invariant.ts | 30 +- packages/fs/tool-fs/src/invariant.ts | 30 +- .../guard/repeat-tool-guard/src/invariant.ts | 26 +- packages/hooks/hook-protocol/src/invariant.ts | 103 ++++-- .../hook-protocol/tests/invariant.spec.ts | 86 +++++ packages/hooks/hooks-claude/src/invariant.ts | 41 +-- .../hooks-claude/tests/invariant.spec.ts | 26 -- packages/hooks/hooks-codex/src/invariant.ts | 39 +-- .../hooks/hooks-codex/tests/invariant.spec.ts | 26 -- packages/llm/llm-deepseek/src/invariant.ts | 28 +- packages/llm/llm-pi-ai/src/invariant.ts | 28 +- packages/llm/llm/src/invariant.ts | 91 +++++- packages/llm/llm/tests/invariant.spec.ts | 86 +++++ packages/llm/llm/tests/service.spec.ts | 4 +- packages/llm/token-meter/src/invariant.ts | 29 +- packages/mcp/mcp-client/src/invariant.ts | 29 +- .../sandbox/sandbox-local/src/invariant.ts | 28 +- packages/sandbox/sandbox/src/invariant.ts | 22 +- .../sandbox/sandbox/tests/invariant.spec.ts | 43 --- packages/sdk/create-sdk/src/invariant.ts | 33 +- packages/sdk/helper/src/invariant.ts | 27 +- packages/sdk/scripts/src/invariant.ts | 29 +- packages/sdk/telemetry/src/invariant.ts | 26 +- .../src/invariant.ts | 28 +- .../src/invariant.ts | 28 +- .../session-persistence/src/invariant.ts | 19 +- .../session-query/src/invariant.ts | 31 +- packages/skill/skill-local/src/invariant.ts | 28 +- packages/skill/skill/src/invariant.ts | 28 +- packages/skill/tool-skill/src/invariant.ts | 30 +- packages/spill/spill-local/src/invariant.ts | 28 +- packages/spill/spill-policy/src/invariant.ts | 32 +- packages/spill/spill/src/invariant.ts | 22 +- .../subagent/subagent-acp/src/invariant.ts | 28 +- .../subagent/subagent-fork/src/invariant.ts | 28 +- .../subagent-inprocess/src/invariant.ts | 22 +- .../subagent/subagent-spawn/src/invariant.ts | 28 +- .../subagent-subprocess/src/invariant.ts | 35 +- packages/subagent/subagent/src/invariant.ts | 89 +++++- .../subagent/subagent/tests/invariant.spec.ts | 82 +++++ .../subagent/tool-subagent/src/invariant.ts | 30 +- .../support/acp-snapshot/src/invariant.ts | 32 +- .../agent-loop-testkit/src/invariant.ts | 23 +- packages/support/invariants/README.md | 46 +-- packages/support/invariants/src/index.ts | 298 +----------------- packages/support/invariants/src/invariant.ts | 29 +- .../support/invariants/tests/service.spec.ts | 287 ----------------- packages/support/llm-replay/src/invariant.ts | 31 +- .../support/loader-smoke/src/invariant.ts | 30 +- packages/tasks/tasks/src/invariant.ts | 56 +++- packages/tasks/tasks/tests/invariant.spec.ts | 87 +++++ packages/tasks/tool-tasks/src/invariant.ts | 30 +- .../timeout/timeout-policy/src/invariant.ts | 28 +- packages/todo/tool-todo/src/invariant.ts | 47 ++- .../todo/tool-todo/tests/invariant.spec.ts | 53 ++++ packages/ui/acp/src/invariant.ts | 35 +- packages/ui/app-boot/src/invariant.ts | 26 +- packages/ui/jsonrpc/src/invariant.ts | 28 +- packages/ui/permission/src/invariant.ts | 35 +- .../ui/permission/tests/invariant.spec.ts | 42 +++ packages/ui/stdio/src/invariant.ts | 30 +- packages/ui/tool-ask-user/src/invariant.ts | 29 +- packages/ui/tui/src/invariant.ts | 31 +- packages/ui/user-approval/src/invariant.ts | 89 +++++- .../ui/user-approval/tests/invariant.spec.ts | 72 +++++ packages/ui/user-interaction/src/invariant.ts | 28 +- packages/util/brand/src/invariant.ts | 20 +- packages/util/home/src/invariant.ts | 25 +- packages/util/paths/src/invariant.ts | 26 +- packages/util/retention/src/invariant.ts | 31 +- packages/util/timeout/src/invariant.ts | 26 +- packages/web/tool-web/src/invariant.ts | 30 +- packages/web/web-fetch-local/src/invariant.ts | 28 +- .../web/web-search-deepseek/src/invariant.ts | 25 +- packages/web/web-search-exa/src/invariant.ts | 28 +- .../web-search-perplexity/src/invariant.ts | 25 +- packages/web/web/src/invariant.ts | 28 +- .../workflow/tool-workflow/src/invariant.ts | 30 +- .../workflow-workerthread/src/invariant.ts | 28 +- packages/workflow/workflow/src/invariant.ts | 126 +++++++- .../workflow/workflow/tests/invariant.spec.ts | 108 +++++++ .../workflow/workflow/tests/workflow.spec.ts | 10 + scripts/package-invariants.spec.ts | 83 ++--- scripts/package-invariants.ts | 70 ++-- scripts/test-invariants.spec.ts | 14 + scripts/test-invariants.ts | 14 + tsconfig.base.json | 30 ++ 124 files changed, 2923 insertions(+), 2334 deletions(-) create mode 100644 packages/bash/bash/tests/invariant.spec.ts create mode 100644 packages/compact/compact/tests/invariant.spec.ts create mode 100644 packages/context/time-context/tests/invariant.spec.ts create mode 100644 packages/core/system-prompt/tests/invariant.spec.ts create mode 100644 packages/core/tools/tests/invariant.spec.ts create mode 100644 packages/fs/fs/tests/invariant.spec.ts create mode 100644 packages/hooks/hook-protocol/tests/invariant.spec.ts delete mode 100644 packages/hooks/hooks-claude/tests/invariant.spec.ts delete mode 100644 packages/hooks/hooks-codex/tests/invariant.spec.ts create mode 100644 packages/llm/llm/tests/invariant.spec.ts delete mode 100644 packages/sandbox/sandbox/tests/invariant.spec.ts create mode 100644 packages/subagent/subagent/tests/invariant.spec.ts create mode 100644 packages/tasks/tasks/tests/invariant.spec.ts create mode 100644 packages/todo/tool-todo/tests/invariant.spec.ts create mode 100644 packages/ui/permission/tests/invariant.spec.ts create mode 100644 packages/ui/user-approval/tests/invariant.spec.ts create mode 100644 packages/workflow/workflow/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 48407e2272..a02e27ef7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: 65986fc0b3aab695d8512d9e221052e1db83445f -2026-07-19-package-invariant-runtime-contracts.zh.md: 0ae0bd305692ee71359c5500e477dee22575a9ef +2026-07-19-package-invariant-runtime-contracts.md: 4e15842d1228d82621f91caa18cbe02fd5407dd8 +2026-07-19-package-invariant-runtime-contracts.zh.md: 88b1eb1a79d88865515617d6737fbded10a13b6e diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index 65986fc0b3..4e15842d12 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -1,4 +1,4 @@ -# Agent Note: Executable package invariant contracts +# Agent Note: Meaningful package invariant contracts Status: implemented @@ -6,60 +6,70 @@ English | [中文](2026-07-19-package-invariant-runtime-contracts.zh.md) ## Problem -The package-owned invariant seam made registration and publication exhaustive, but its generated baseline treated package-name ownership as sufficient. An empty installer could satisfy the repository gate while observing no runtime state and rejecting no invalid state. That made the exhaustive count a wiring claim rather than protection for the package contract. +The package-owned invariant seam made publication and registration exhaustive, but its first generated baseline accepted empty installers. A follow-up then replaced those empties with generic assertions about plugin names, injections, effects, service methods, and fixed pure-library examples. Those assertions made every companion executable without making the system safer: TypeScript, Cordis startup, package tests, and module-load tests already enforce those shapes, while the invariant service should detect impossible runtime state. -Every package shape cannot use the same invariant. Cordis plugins own fibers, injections, effects, and services; service seams admit structural third-party implementations; stateful domains need event relations; pure libraries and bin packages expose algebra, parsing, normalization, or entrypoint constraints. The repository needs one enforceable obligation without moving those contracts back into a central product-aware package. +A useful runtime invariant relates observations over time or across a mutable data structure. Examples include a terminal event without its start, an LLM delta for a block that is not open, or a durable result whose identity differs from its request. Merely confirming that a declared method exists, that a plugin has its expected name, or that a constant example still returns a known value is not such a relation. -Vitest mounts each package test's owning companion globally, and one exhaustive topology mounts every companion. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. +Some packages genuinely own no continuously observable relation. Pure utilities, composition-only packages, thin adapters, binaries, and test-support packages may have important contracts, but those contracts are better enforced by types, load checks, focused unit tests, or integration tests. Requiring a synthetic runtime assertion for those packages would optimize for satisfying a gate instead of detecting corruption. ## Decision -### Every companion executes a package contract +### Registration is exhaustive; assertions must be meaningful -Every workspace package keeps its separately published `./invariant` companion and exact npm-name registration, but the installer must execute at least one package-specific check through the bound `fail(message)` reporter. The ownership-baseline generator and its root script entry are removed; generated markers, empty installers, and installers that never reference the reporter are repository errors. +Every workspace package publishes a separately built `./invariant` companion and registers its exact npm package name. A companion does one of two things: -The implemented contracts use four forms: +- installs a package-owned check over an event stream or relevant mutable data structure and reports violations through its bound `fail(message)` reporter; or +- uses an empty installer whose declaration has an owner-specific `No runtime invariant:` comment explaining why the package has no plausible runtime relation to observe. -| Owner shape | Runtime contract | +The empty form is an explicit architectural conclusion, not a generated placeholder. A future package change that introduces mutable state or an event protocol must replace the explanation with the corresponding check. + +The central `dsh-invariants` service owns only configuration, registration uniqueness, child-fiber lifecycle, rollback, disposal, and package-attributed failure. It exposes no generic plugin-shape, service-shape, or startup-assertion helpers and imports no product package. + +### Implemented checks + +The current 91-package workspace has 18 executable companions and 73 justified empty companions. + +| Owner | Runtime relationship | |---|---| -| Stateful session, agent, scope, and agent-loop owners | Validate event ordering, enclosure, status transitions, scoped subjects, and reconstructable model requests. | -| Cordis plugin owners | Validate the plugin's own declared runtime name, required injections, owned effects, provided services, and package-specific all-or-none or config-dependent relations. | -| Cordis service seams | Validate the structural method and descriptor surface of current and future implementations. | -| Pure libraries, bins, and support packages | Validate stable parser mapping, protocol precedence, retention and timeout algebra, path resolution, normalization, environment scrubbing, or deliberately empty runtime entrypoints. | +| `dsh-session` | Strict sequence growth, turn/step enclosure, and same-step tool call/result pairing. | +| `dsh-agent` | Non-repeating agent status and terminal disposal transitions. | +| `dsh-scope` | Scoped-event carrier presence and routed-subject consistency. | +| `dsh-agent-loop` | Frozen loop request reconstruction from the session event log. | +| `dsh-llm` | Stream block grammar, delta type/index matching, single usage, closed blocks, and terminal finish. | +| `dsh-tools` | Monotonic pre/execute/post stages and immutable final execution/result snapshots. | +| `dsh-system-prompt` | Authoritative assembly section, tool, and variable data constraints. | +| `dsh-compact` | Compaction start/summary/end pairing, range endpoints, token counts, and successful-summary presence. | +| `dsh-hook-protocol` | Hook invocation/result correlation, dialect, identity, and duration constraints. | +| `dsh-bash` | Durable sandbox-mode events use the closed sandbox-mode vocabulary. | +| `dsh-fs` | Filesystem decision/observation events carry usable target and version identities. | +| `dsh-subagent` | Provider add/remove and child start/end events preserve identity and pairing. | +| `dsh-permission` | Durable permission decisions name a preset in the active permission table. | +| `dsh-user-approval` | Approval asked/decided records pair by call and use valid outcomes and policies. | +| `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. | +| `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. | +| `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | +| `dsh-time-context` | Plugin-attributed clock readings agree across turn, step, elapsed baseline, rendered time, and event time. | -At implementation time this covers all 91 workspace packages: four stateful companions, 62 plugin-fiber companions, eight service-shape companions, and 17 pure/bin/support companions. +Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. -### Product-independent observers +### Repository gate and tests -`observePluginInvariant` checks existing fibers immediately and future active fibers through a callback/name index behind one root-shared Cordis lifecycle listener pair. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. +`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter. The gate deliberately does not infer semantic quality from method names or helper calls. -`observeServiceInvariant` checks the current service and every later binding. `serviceShapeViolation` validates callable members and non-empty string descriptors structurally instead of using `instanceof`, so conforming third-party backends and complete test doubles remain valid while incomplete stand-ins fail. - -`assertInvariant` handles package algebra. Pure-package companions return an asynchronous installer promise and dynamically import their owner during child startup. The service joins that promise for atomic rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. - -### Gate and test execution - -`verify-package-invariants` discovers every workspace package and retains the publication checks for the exact registration name, `./invariant` export, published files, invariant peer and development dependencies, TypeScript reference, and bundle entry. Its source check additionally parses the local `install` function, rejects a generated marker or empty body, requires a second failure-reporter parameter and its use, and rejects duplicate name-based plugin observers across packages. These AST checks are a minimum acceptance rule, not a claim that source shape proves semantic quality. - -The Vitest setup host mounts `InvariantService` with `{ enabled: true }` before an ordinary Cordis root's first plugin and adds the current test package's companion. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. One exhaustive topology mounts all 91 companions once to prove runtime registration and coverage; focused selection, lifecycle, and owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. - -Helper tests reject invalid plugin names, missing injections, effects, services, custom relations, malformed service shapes, and failed assertions. Package suites then activate real plugins across their existing config and HMR paths. Test-only service stand-ins must implement the complete checked seam rather than bypass global invariants. +Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology loads all companions to prove registration and disposal wiring. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. ## Alternatives considered -- **Keep generated ownership-only companions.** Rejected because registration without an executable assertion cannot reject a broken package and makes the exhaustive gate misleading. -- **Generate one synthetic assertion into every package.** Rejected because a universal assertion would again optimize for satisfying the gate instead of protecting an owner-specific contract. -- **Move the per-package contract matrix into `dsh-invariants`.** Rejected because product imports, vocabulary, and change ownership would return to the central service. -- **Import every owner entrypoint statically from its companion.** Rejected because owning and exhaustive test hosts would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. -- **Require first-party service-class identity.** Rejected because service seams are structural extension boundaries; `instanceof` would reject valid external implementations and test doubles. -- **Register invariants implicitly from package root entrypoints.** Rejected for the composition-order and hidden-effect reasons in the package-owned service RFC. +- **Keep generated empty companions.** Rejected because an unexplained placeholder can survive after a package gains a meaningful runtime relation. +- **Require an assertion from every package.** Rejected because method-presence, plugin-shape, and fixed-example assertions duplicate stronger type, load, and unit-test contracts without checking runtime consistency. +- **Keep generic shape helpers in the service.** Rejected because they blur compile-time API validation with runtime invariants and encourage centrally defined product assumptions. +- **Move the product checks into the service.** Rejected because product vocabulary, dependencies, tests, and change ownership belong with the package that emits the data. +- **Register companions implicitly from root entrypoints.** Rejected because composition order and optional service presence would create hidden effects. ## Consequences -- Every package contributes an executable check; adding a package without one fails the top-level gate. -- The invariant service remains product-independent while providing reusable lifecycle and shape observers. -- Ordinary unit, snapshot, and e2e roots run with global invariant enablement and the test package's companion; one exhaustive topology registers every companion. -- Plugin names used for name-based observation must be unique within one Cordis root; packages may opt into exact callback identity when safe. -- Pure-package checks sample stable startup contracts. Mutable behavior must use an event, service, or plugin-fiber observer. -- Relevant companion work runs during package tests and selected deployments, trading bounded startup cost for immediate package-attributed failures. -- The original regex selection, blocklist precedence, registration uniqueness, rollback, disposal, and HMR contracts remain unchanged. +- Every package has visible ownership and publication wiring, but only packages with a plausible runtime relation add listeners or trace state. +- Empty companions remain reviewable decisions with package-specific explanations and fail the gate if the explanation is removed. +- Type declarations, Cordis loadability, plugin metadata, service method surfaces, and pure algebra remain covered by their owning compile, load, unit, or integration gates. +- Runtime failures identify the owning npm package and point to an inconsistent observation rather than restating a required API shape. +- The original selection, blocklist precedence, duplicate ownership, rollback, disposal, and HMR service contracts remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 0ae0bd3056..88b1eb1a79 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -1,65 +1,75 @@ -# Agent Note: 可执行的包不变式契约 +# Agent Note:有意义的包不变量契约 -Status: implemented +状态:已实施 [English](2026-07-19-package-invariant-runtime-contracts.md) | 中文 ## 问题 -包拥有的不变式接缝让注册与发布覆盖完整,但生成的基线把包名所有权视为充分条件。空 installer 可以通过仓库门禁,却不观察任何运行时状态,也不拒绝任何无效状态。这样一来,完整计数只能证明接线存在,不能保护包契约。 +包自有不变量接缝让发布和注册实现了全覆盖,但最初的生成基线允许空安装器。后续方案又用针对插件名称、注入、effect、服务方法和固定纯函数示例的通用断言替代这些空实现。这些断言虽然让每个 companion 都能执行,却没有提高系统安全性:TypeScript、Cordis 启动、包测试和模块加载测试已经约束这些形状,而不变量服务应当发现不可能出现的运行时状态。 -不同包形态不能使用同一种不变式。Cordis 插件拥有 fiber、注入、effect 与服务;服务接缝允许结构兼容的第三方实现;有状态领域需要事件关系;纯库和 bin 包暴露代数、解析、规范化或入口约束。仓库需要一个可执行的统一义务,同时不能把这些契约重新移回了解产品语义的中央包。 +有用的运行时不变量会关联时间上的多个观测,或关联可变数据结构中的多个部分。例如:终止事件没有对应的开始事件、LLM delta 指向未打开的 block,或持久化结果的身份与请求不同。仅确认声明的方法存在、插件名称符合预期,或常量示例仍返回已知值,都不属于这种关系。 -Vitest 会为每个包测试全局挂载其所有者伴随插件,并由一个完整拓扑挂载全部伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 +有些包确实没有可持续观测的关系。纯工具、仅负责组合的包、薄适配器、可执行入口和测试支持包可能仍有重要契约,但类型检查、加载检查、聚焦单元测试或集成测试更适合执行这些契约。强迫这些包添加合成运行时断言,只会让实现围绕通过门禁优化,而不是检测损坏。 ## 决策 -### 每个伴随插件都执行包契约 +### 注册必须全覆盖;断言必须有意义 -每个工作区包保留独立发布的 `./invariant` 伴随插件和准确 npm 包名注册,但 installer 必须通过绑定的 `fail(message)` 报告器执行至少一个包专属检查。删除所有权基线生成器及其根脚本入口;生成标记、空 installer 和从不引用报告器的 installer 都属于仓库错误。 +每个 workspace 包都发布单独构建的 `./invariant` companion,并用完整 npm 包名注册。companion 只能采用以下两种形式之一: -实现后的契约采用四种形态: +- 安装包自有的事件流或相关可变数据结构检查,并通过绑定的 `fail(message)` 报告器报告违规;或 +- 使用空安装器,并在其声明前写一条该包专属的 `No runtime invariant:` 注释,说明为什么该包没有合理的运行时关系可供观测。 -| 所有者形态 | 运行时契约 | +空形式是明确的架构结论,不是生成占位符。如果后续包变更引入可变状态或事件协议,就必须用相应检查替换该说明。 + +中央 `dsh-invariants` 服务只负责配置、注册唯一性、子 fiber 生命周期、回滚、释放和归属到包的失败。它不暴露通用插件形状、服务形状或启动断言 helper,也不导入产品包。 + +### 已实施的检查 + +当前 91 个包的 workspace 包含 18 个可执行 companion 和 73 个有理由的空 companion。 + +| 所有者 | 运行时关系 | |---|---| -| 有状态的 session、agent、scope 与 agent-loop 所有者 | 验证事件顺序、包围关系、状态转换、作用域主体和可重建的模型请求。 | -| Cordis 插件所有者 | 验证插件自身声明的运行时名称、必要注入、拥有的 effect、提供的服务,以及包专属的全有或全无关系或配置依赖关系。 | -| Cordis 服务接缝 | 验证当前和未来实现的结构化方法与描述字段表面。 | -| 纯库、bin 与支持包 | 验证稳定的解析映射、协议优先级、保留与超时代数、路径解析、规范化、环境清理或刻意为空的运行时入口。 | +| `dsh-session` | 序号严格递增、turn/step 包围关系,以及同一 step 内的工具调用/结果配对。 | +| `dsh-agent` | agent 状态不得重复,并且不能离开终态 disposed。 | +| `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 | +| `dsh-agent-loop` | 从 session 事件日志重建冻结的 loop 请求。 | +| `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 | +| `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 | +| `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 | +| `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 | +| `dsh-hook-protocol` | hook invocation/result 的关联、dialect、身份和 duration 约束。 | +| `dsh-bash` | 持久化 sandbox-mode 事件必须使用封闭的 sandbox-mode 词表。 | +| `dsh-fs` | 文件系统决策/观测事件必须携带可用的 target 和 version 身份。 | +| `dsh-subagent` | provider add/remove 和 child start/end 事件必须保持身份与配对。 | +| `dsh-permission` | 持久化 permission 决策必须引用当前 permission 表中的 preset。 | +| `dsh-user-approval` | approval asked/decided 记录按 call 配对,并使用有效 outcome 和 policy。 | +| `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | +| `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 | +| `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | +| `dsh-time-context` | 标注插件来源的时钟 reading 在 turn、step、elapsed baseline、渲染时间和事件时间之间保持一致。 | -实现时覆盖全部 91 个工作区包:四个有状态伴随插件、62 个插件 fiber 伴随插件、八个服务形状伴随插件和 17 个纯库、bin 或支持包伴随插件。 +基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 -### 与产品无关的观察器 +### 仓库门禁与测试 -`observePluginInvariant` 会立即检查已有 fiber,并通过根上下文共享的一对 Cordis 生命周期监听器背后的 callback/名称索引检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 +`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器。门禁不会通过方法名或 helper 调用推断语义质量。 -`observeServiceInvariant` 检查当前服务及之后的每次绑定。`serviceShapeViolation` 以结构方式验证可调用成员和非空字符串描述字段,而不使用 `instanceof`;因此符合契约的第三方后端和完整测试替身有效,不完整替身会失败。 - -`assertInvariant` 处理包代数。纯包伴随插件返回异步 installer promise,并在子 fiber 启动期间动态导入所有者。服务会等待该 promise 以保证原子回滚,同时允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 - -### 门禁与测试执行 - -`verify-package-invariants` 发现每个工作区包,并保留准确注册名、`./invariant` export、发布文件、不变式 peer 与开发依赖、TypeScript 引用和 bundle 入口的发布检查。源码检查还会解析本地 `install` 函数,拒绝生成标记或空函数体,要求第二个失败报告器参数及其使用,并拒绝跨包重复的按名称插件观察器。这些 AST 检查只是最低接收规则,并不宣称源码形状足以证明语义质量。 - -Vitest setup host 会在普通 Cordis 根上下文启动第一个插件前,以 `{ enabled: true }` 挂载 `InvariantService`,并添加当前测试包的伴随插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。一个完整拓扑会一次挂载全部 91 个伴随插件,以证明运行时注册与覆盖率;选择、生命周期和所有者聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 - -辅助测试会拒绝错误插件名、缺失注入、effect、服务或自定义关系、错误服务形状和失败断言。随后,包套件在已有配置与 HMR 路径上激活真实插件。测试专用服务替身必须实现完整的已检查接缝,不能绕过全局不变式。 +Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑加载全部 companion,以证明注册和释放 wiring。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 ## 考虑过的替代方案 -- **保留生成的仅声明所有权伴随插件。** 不予采纳,因为没有可执行断言的注册无法拒绝损坏的包,也会让完整门禁产生误导。 -- **为每个包生成一个合成断言。** 不予采纳,因为通用断言仍是在优化如何通过门禁,而不是保护所有者专属契约。 -- **把逐包契约矩阵移入 `dsh-invariants`。** 不予采纳,因为产品导入、词汇和变更所有权会重新回到中央服务。 -- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为所有者测试 host 与完整测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 -- **要求第一方服务类身份。** 不予采纳,因为服务接缝是结构化扩展边界;`instanceof` 会拒绝有效的外部实现和测试替身。 -- **从包根入口隐式注册不变式。** 因包拥有服务 RFC 中的组合顺序与隐藏 effect 问题而不予采纳。 +- **保留生成的空 companion。** 拒绝,因为包获得有意义的运行时关系后,没有解释的占位符仍可能继续存在。 +- **要求每个包都执行断言。** 拒绝,因为方法存在性、插件形状和固定示例断言会重复更强的类型、加载和单元测试契约,却没有检查运行时一致性。 +- **在服务中保留通用形状 helper。** 拒绝,因为这会混淆编译期 API 验证和运行时不变量,并鼓励在中央定义产品假设。 +- **把产品检查移入服务。** 拒绝,因为产品词汇、依赖、测试和变更所有权应归属于产生这些数据的包。 +- **从根入口隐式注册 companion。** 拒绝,因为组合顺序和可选服务存在性会产生隐藏 effect。 ## 后果 -- 每个包都贡献可执行检查;新增包若没有检查,会在顶层门禁失败。 -- 不变式服务保持与产品无关,同时提供可复用的生命周期与形状观察器。 -- 普通单元、snapshot 与 e2e 根上下文均全局启用不变式并注册当前测试包的伴随插件;一个完整拓扑注册全部伴随插件。 -- 用于按名称观察的插件名在一个 Cordis 根上下文内必须唯一;安全时包可以选择准确 callback 身份。 -- 纯包检查对稳定启动契约采样;可变行为必须使用事件、服务或插件 fiber 观察器。 -- 包测试和被选部署会执行相关伴随工作,以有界启动成本换取即时且带包归属的失败。 -- 原有正则选择、blocklist 优先级、注册唯一性、回滚、dispose 与 HMR 契约保持不变。 +- 每个包都有可见的所有权与发布 wiring,但只有具备合理运行时关系的包才会增加 listener 或 trace 状态。 +- 空 companion 是带包专属说明、可评审的决策;删除说明后门禁会失败。 +- 类型声明、Cordis 可加载性、插件 metadata、服务方法形状和纯代数继续由所属的编译、加载、单元或集成门禁覆盖。 +- 运行时失败会标明所属 npm 包,并指出不一致的观测,而不是复述必要的 API 形状。 +- 原有 selection、blocklist 优先级、重复所有权、回滚、释放和 HMR 服务契约保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index 9ab8642f4b..c37979ae66 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: 552b088c1cafc2fa762487f57fa1d4ad64390f7e -2026-07-19-package-owned-invariant-service.zh.md: 84bdc6a6a3baefe95713f8ce5b8a4e2af49b64eb +2026-07-19-package-owned-invariant-service.md: bd167f8da1e855b703b75a60e2dbd8959f5bf9b3 +2026-07-19-package-owned-invariant-service.zh.md: 6bd4f30123b7c7ba39266d5d31dd4b378604fd5a diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index 552b088c1c..bd167f8da1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -18,7 +18,7 @@ Package ownership must also be exhaustive. Without a mechanical repository rule, `@deepseek-ai/dsh-invariants` is a product-independent Cordis service plugin that registers `ctx.invariants`. It owns configuration, registration uniqueness, child-fiber lifecycle, and package-attributed failures. It imports no session, agent, scope, or agent-loop package and contains none of their checks. -Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name and installs an executable package-specific contract. Generated ownership-only installers are forbidden by the follow-up [runtime-contract Agent Note](2026-07-19-package-invariant-runtime-contracts.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. +Every workspace package publishes a `./invariant` companion plugin that registers its exact full npm name. A companion checks a meaningful event or mutable-data relationship when its owner has one; otherwise it carries an owner-specific explanation for its empty installer. Generated ownership placeholders and synthetic API-shape assertions are forbidden by the follow-up [runtime-contract Agent Note](2026-07-19-package-invariant-runtime-contracts.md). Package root entrypoints do not import or register diagnostics implicitly, so loading a root package does not change runtime checking or require the invariant service. ### Configuration and selection @@ -55,7 +55,7 @@ Registration setup is transactional. If an installer fails after registering lis The former functional-plugin entrypoint and one-argument `InvariantError` constructor are not retained as compatibility surfaces. The repository is pre-release and all call sites move to the service and package-attributed error together. -### Stateful companions and exhaustive ownership +### Initial stateful companions and exhaustive ownership | Companion entry | Registration name | Owned checks | |---|---|---| @@ -64,9 +64,9 @@ The former functional-plugin entrypoint and one-argument `InvariantError` constr | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | model-request reconstruction | -These four owners contain stateful checks and focused tests. Other owners check their plugin fibers and effects, structural service implementations, or stable pure-library algebra. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. +These four owners supplied the initial stateful checks. The follow-up runtime-contract decision adds checks for fourteen more owners with real event or mutable-data relationships and records justified empty companions for the rest. Every companion is a separately bundled `./invariant` export with its own declarations and Loader-safe namespace plugin shape; the service package's own companion imports its local service type to avoid a self-dependency. -`verify-package-invariants` discovers every workspace package and rejects missing companion source, generated markers, empty or reporter-free installers, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. +`verify-package-invariants` discovers every workspace package and rejects missing companion source, generated markers, unexplained empty installers, non-empty installers that omit or ignore the reporter, foreign or unresolved registration names, missing `./invariant` exports or published files, missing invariant peer/development dependencies and project references, and bundle overrides that omit the companion entry. ### Scoped-event semantic map @@ -80,7 +80,7 @@ Workspace constraints recognize the separate invariant bundle, and package expor ## Testing -Service tests cover defaults, global disablement, allow/block selection, blocklist precedence, anchoring, unanchored matching, case sensitivity, invalid configuration, zero-match patterns, late registration, duplicate ownership, disposal, rollback, and HMR re-registration. Owner tests keep each invariant's positive and negative behavior beside its source. +Service tests cover defaults, global disablement, allow/block selection, blocklist precedence, anchoring, unanchored matching, case sensitivity, invalid configuration, zero-match patterns, late registration, duplicate ownership, disposal, rollback, and HMR re-registration. Owners with executable checks keep positive and negative behavior beside the companion source. Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. @@ -96,10 +96,10 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s ## Consequences - Product packages own and test their relational assertions while the service stays product-independent. -- Every package pays the publication, dependency, and runtime-check cost of an executable invariant companion. +- Every package pays the publication and dependency cost of a companion; only owners with a meaningful runtime relationship add listener or trace-state cost. - Standard compositions can disable all checks or select package names without changing their plugin tree. - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. -- One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. +- One selected executable contribution adds one child fiber and its listener/state cost; a selected empty contribution has no listener or trace-state cost, while filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. - Ordinary Vitest roots install the owning test package's selected companion; one exhaustive topology pays the full child-fiber cost once for repository-wide registration coverage. - Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index 84bdc6a6a3..6bd4f30123 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -18,7 +18,7 @@ Status: implemented `@deepseek-ai/dsh-invariants` 是与产品无关的 Cordis 服务插件,注册 `ctx.invariants`。它只负责配置、注册唯一性、子 fiber 生命周期和带包归属的失败;不导入 session、agent、scope 或 agent-loop 包,也不包含这些包的检查。 -工作区内的每个包都发布 `./invariant` 伴随插件,注册自己完整且准确的 npm 包名,并安装可执行的包专属契约。后续的[运行时契约 Agent Note](2026-07-19-package-invariant-runtime-contracts.md) 禁止生成的仅声明所有权 installer。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 +工作区内的每个包都发布 `./invariant` 伴随插件,注册自己完整且准确的 npm 包名。如果所有者具备有意义的事件或可变数据关系,companion 就检查该关系;否则空 installer 必须携带该所有者专属的说明。后续的[运行时契约 Agent Note](2026-07-19-package-invariant-runtime-contracts.md) 禁止生成的所有权占位符和合成 API 形状断言。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 ### 配置与选择 @@ -55,7 +55,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容表面保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。 -### 有状态伴随插件与完整所有权 +### 首批有状态伴随插件与完整所有权 | 伴随入口 | 注册名 | 所属检查 | |---|---|---| @@ -64,9 +64,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 | `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped event carrier 存在性与主体一致性 | | `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | 模型请求重建 | -这四个所有者保存有状态检查与聚焦测试。其他所有者检查自己的插件 fiber 与 effect、结构化服务实现或稳定的纯库代数。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 +这四个所有者提供了首批有状态检查。后续运行时契约决策为另外十四个确有事件或可变数据关系的所有者增加检查,并为其余包记录有理由的空 companion。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 -`verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、空 installer、不使用失败报告器的 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。 +`verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、没有解释的空 installer、缺少或不使用失败报告器的非空 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。 ### Scoped event 语义映射 @@ -80,7 +80,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 ## 测试 -服务测试覆盖默认值、全局关闭、allow/block 选择、blocklist 优先级、锚定与非锚定匹配、大小写敏感、无效配置、零匹配模式、延迟注册、重复所有权、dispose、回滚和 HMR 重新注册。所有者测试把各不变式的正向与负向行为保留在其源码旁边。 +服务测试覆盖默认值、全局关闭、allow/block 选择、blocklist 优先级、锚定与非锚定匹配、大小写敏感、无效配置、零匹配模式、延迟注册、重复所有权、dispose、回滚和 HMR 重新注册。具备可执行检查的所有者会把正向与负向行为保留在 companion 源码旁边。 组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 @@ -96,10 +96,10 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 ## 后果 - 产品包拥有并测试自己的关系断言,服务保持与产品无关。 -- 每个包都要承担可执行不变式伴随插件带来的发布、依赖与运行时检查成本。 +- 每个包都承担 companion 的发布与依赖成本;只有具备有意义运行时关系的所有者才增加 listener 或 trace 状态成本。 - 标准组合无需改变插件树即可关闭全部检查或按包名选择。 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 -- 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 +- 每个选中的可执行贡献增加一个子 fiber 及其 listener/状态成本;选中的空贡献不增加 listener 或 trace 状态成本,被过滤注册则只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 - 普通 Vitest 根上下文会安装当前测试包中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。 - 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 1219088e70..16ee490395 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,7 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns executable invariants.** Publish `./invariant`, register its manifest name, and enforce a runtime contract with the bound reporter; generated, empty, and reporter-free installers fail `verify-package-invariants` ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md)). +- **Every package owns an explicit invariant companion.** Publish `./invariant` and register its manifest name. Check observable event or mutable-data relationships; when none exists, keep an empty installer with a package-specific `No runtime invariant:` explanation instead of inventing an API-shape assertion. Generated companions, unexplained empties, and non-empty installers that ignore the reporter fail `verify-package-invariants` ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md)). Naming notes: diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts index 397bf0d6dc..3cc7bd62e2 100644 --- a/packages/bash/bash-local/src/invariant.ts +++ b/packages/bash/bash-local/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash-local`. @module @deepseek-ai/dsh-bash-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-bash-local`. + * @module @deepseek-ai/dsh-bash-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local' /** Cordis companion plugin name. */ export const name = 'bash-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LocalBashExecutor', - effects: [ - 'ctx.provide("bash")', - 'local bash teardown', - ], - services: [ - 'bash', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts index f758c90f6f..b79b626033 100644 --- a/packages/bash/bash-sandbox/src/invariant.ts +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -1,30 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash-sandbox`. @module @deepseek-ai/dsh-bash-sandbox/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-bash-sandbox`. + * @module @deepseek-ai/dsh-bash-sandbox/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox' /** Cordis companion plugin name. */ export const name = 'bash-sandbox-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SandboxBashExecutor', - inject: [ - 'sandbox', - ], - effects: [ - 'ctx.provide("bash")', - ], - services: [ - 'bash', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts index 91f135b1bb..1229e19e4c 100644 --- a/packages/bash/bash/src/invariant.ts +++ b/packages/bash/bash/src/invariant.ts @@ -1,24 +1,30 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-bash`. @module @deepseek-ai/dsh-bash/invariant */ +/** Package-owned session-event invariants for the bash seam. @module @deepseek-ai/dsh-bash/invariant */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { SANDBOX_MODES } from './session-mode.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-bash' /** Cordis companion plugin name. */ export const name = 'bash-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ +/** Install validation for the durable sandbox-mode vocabulary. */ const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'bash', value => serviceShapeViolation(value, { - methods: ['resolve', 'run', 'start'], - })) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const event = (args as [Session, SessionEvent])[1] + if (event.type === 'bash/sandbox-mode' && !SANDBOX_MODES.includes(event.data.mode)) { + fail(`bash/sandbox-mode carries unknown mode ${JSON.stringify(event.data.mode)}`) + } + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the bash invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/bash/bash/tests/invariant.spec.ts b/packages/bash/bash/tests/invariant.spec.ts new file mode 100644 index 0000000000..9d6209042f --- /dev/null +++ b/packages/bash/bash/tests/invariant.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as BashInvariant from '@deepseek-ai/dsh-bash/invariant' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(BashInvariant) + return ctx +} + +function modeEvent(mode: string): SessionEvent { + return { type: 'bash/sandbox-mode', seq: 0, time: 0, data: { mode } } as SessionEvent +} + +describe('bash invariants', () => { + it('accepts the closed sandbox vocabulary and ignores unrelated events', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, modeEvent('workspace-write')) }).not.toThrow() + expect(() => { ctx.emit('session/event', {} as Session, { + type: 'turn/start', seq: 0, time: 0, data: {}, + } as SessionEvent) }).not.toThrow() + expect(() => { ctx.emit('tools/change') }).not.toThrow() + }) + + it('rejects an unknown durable sandbox mode', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, modeEvent('host-root')) }) + .toThrow(/unknown mode "host-root"/) + }) +}) diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts index ca63f9ff77..0620f0cfa9 100644 --- a/packages/bash/tool-bash/src/invariant.ts +++ b/packages/bash/tool-bash/src/invariant.ts @@ -1,34 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-bash`. @module @deepseek-ai/dsh-tool-bash/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash`. + * @module @deepseek-ai/dsh-tool-bash/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash' /** Cordis companion plugin name. */ export const name = 'tool-bash-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-bash', - inject: [ - 'tools', - 'bash', - 'systemPrompt', - ], - effects: [ - 'ctx.provide("bashEnv")', - 'bashEnv.register()', - 'tools.register()', - ], - services: [ - 'bashEnv', - ], - }) -} +/** + * No runtime invariant: the environment registry validates ownership and collected values at each + * mutation/read; it publishes no independent snapshot that a companion could cross-check. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -37,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts index 63daf58ae9..3455104441 100644 --- a/packages/code-runtime/code-runtime-worker/src/invariant.ts +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -1,31 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-code-runtime-worker`. + * Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-worker`. * @module @deepseek-ai/dsh-code-runtime-worker/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker' /** Cordis companion plugin name. */ export const name = 'code-runtime-worker-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'WorkerCodeRuntime', - effects: [ - 'ctx.provide("codeRuntime")', - 'worker code-runtime teardown', - ], - services: [ - 'codeRuntime', - ], - }) -} +/** + * No runtime invariant: this process-boundary implementation exposes no same-process event relation; + * worker protocol and built-worker tests cover it. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -34,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts index 1ee564c60b..9c4019699b 100644 --- a/packages/code-runtime/code-runtime/src/invariant.ts +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-code-runtime`. @module @deepseek-ai/dsh-code-runtime/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime`. + * @module @deepseek-ai/dsh-code-runtime/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime' /** Cordis companion plugin name. */ export const name = 'code-runtime-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'codeRuntime', (value) => { - const violation = serviceShapeViolation(value, { - methods: ['run'], - stringProperties: ['language', 'isolation'], - }) - if (violation !== undefined) return violation - const service = value as { language: string; isolation: string } - return /^[a-z][a-z0-9-]*$/.test(service.language) && /^[a-z][a-z0-9-]*$/.test(service.isolation) - ? undefined - : 'code runtime language and isolation must be lowercase identifiers' - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 68bc809de7..56a32930c9 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import { InvariantError } from '@deepseek-ai/dsh-invariants' /** * Minimal concrete runtime: records requests, "executes" by invoking every @@ -86,24 +85,4 @@ describe('CodeRuntime service seam', () => { await expect(ctx.plugin(StubRuntime)).rejects.toThrow(/registered/) }) - it.each([ - [{ language: 'typescript', isolation: 'worker' }, /must expose method "run"/], - [{ language: 'TypeScript', isolation: 'worker', run() {} }, /must be lowercase identifiers/], - ])('rejects an invalid runtime implementation through the package invariant', async (value, message) => { - const ctx = new Context() - const invalidRuntime = { - name: 'invalid-code-runtime', - apply(child: Context) { - child.provide('codeRuntime', value as unknown as CodeRuntime) - }, - } - let caught: unknown - try { - await ctx.plugin(invalidRuntime) - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(InvariantError) - expect((caught as Error).message).toMatch(message) - }) }) diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts index 3e034a2f58..172790d233 100644 --- a/packages/compact/compact-basic/src/invariant.ts +++ b/packages/compact/compact-basic/src/invariant.ts @@ -1,46 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-compact-basic`. @module @deepseek-ai/dsh-compact-basic/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-compact-basic`. + * @module @deepseek-ai/dsh-compact-basic/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact-basic' /** Cordis companion plugin name. */ export const name = 'compact-basic-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'BasicCompactService', - inject: [ - 'llm', - 'tokenMeter', - ], - effects: [ - 'ctx.provide("compact")', - ], - services: [ - 'compact', - ], - validate: (fiber, effectLabels) => { - const automaticEffects = [ - 'ctx.on("agent/post-step")', - 'ctx.on("agent/request-error")', - ] - const installed = automaticEffects.filter(label => effectLabels.has(label)).length - const automatic = (fiber.config as { auto?: boolean }).auto !== false - if (automatic && installed !== automaticEffects.length) { - return 'automatic compaction must install both pressure and overflow listeners' - } - if (!automatic && installed !== 0) { - return 'auto:false must install neither automatic compaction listener' - } - return undefined - }, - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -49,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 762289c9d9..0b118ac245 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -5,7 +5,7 @@ import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' -import type { CompactService, CompactionResult } from '@deepseek-ai/dsh-compact' +import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -199,28 +199,6 @@ describe('compact configuration and defaults', () => { } }) - it.each([ - [{ auto: true }, false, /must install both pressure and overflow listeners/], - [{ auto: false }, true, /must install neither automatic compaction listener/], - ])('rejects an inconsistent automatic-listener topology through the package invariant', async (config, installListener, message) => { - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(TokenMeterService) - const invalidCompact = { - name: 'BasicCompactService', - inject: ['llm', 'tokenMeter'], - apply(child: Context, _config: { auto?: boolean }) { - child.provide('compact', { - compactIfNeeded() {}, - compactRegion() {}, - } as unknown as CompactService) - if (installListener) { - child.effect(() => () => {}, 'ctx.on("agent/post-step")') - } - }, - } - await expect(ctx.plugin(invalidCompact, config)).rejects.toThrow(message) - }) }) describe('pressure measurement and retention', () => { diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index 974f7a29ca..59fdc806b2 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -1,24 +1,106 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-compact`. @module @deepseek-ai/dsh-compact/invariant */ +/** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type {} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-compact' /** Cordis companion plugin name. */ export const name = 'compact-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'compact', value => serviceShapeViolation(value, { - methods: ['compactIfNeeded', 'compactRegion'], - })) +interface CompactionTrace { + turn: number + summarized: boolean } +type CompactionTransition = + | { kind: 'start'; turn: number } + | { kind: 'summary'; turn: number } + | { kind: 'end' } + +/** Validate one compaction event without advancing committed trace state. */ +function validateCompactionEvent( + open: CompactionTrace | undefined, + event: SessionEvent, + fail: InvariantFailure, +): CompactionTransition | undefined { + if (event.type === 'compact/start') { + if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`) + return { kind: 'start', turn: event.data.turn } + } + if (event.type === 'compact/summary') { + if (open === undefined) fail('compact/summary has no matching compact/start') + if (open.summarized) fail('compact/summary repeated within one compaction') + const seqs = event.data.shadowedSeqs + if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty') + if (seqs[0] !== event.data.shadowedRange.start || seqs.at(-1) !== event.data.shadowedRange.end) { + fail('compact/summary shadowedRange must match the first and last shadowedSeqs') + } + if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) { + fail('compact/summary shadowedTokenCount must be a non-negative safe integer') + } + return { kind: 'summary', turn: open.turn } + } + if (event.type !== 'compact/end') return undefined + if (open === undefined) fail('compact/end has no matching compact/start') + if (event.data.turn !== open.turn) { + fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`) + } + if (event.data.error === undefined && !open.summarized) { + fail('successful compact/end requires one compact/summary') + } + return { kind: 'end' } +} + +/** Apply one committed compaction transition. */ +function applyCompactionTransition( + transition: CompactionTransition, +): CompactionTrace | undefined { + if (transition.kind === 'start') return { turn: transition.turn, summarized: false } + if (transition.kind === 'summary') return { turn: transition.turn, summarized: true } + return undefined +} + +/** Install compaction start/summary/end checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap() + const staged = new WeakMap() + const seed = (session: Session): void => { + let open: CompactionTrace | undefined + for (const event of session.events) { + const transition = validateCompactionEvent(open, event, fail) + if (transition !== undefined) open = applyCompactionTransition(transition) + } + if (open !== undefined) traces.set(session, open) + } + const traceFor = (session: Session): CompactionTrace | undefined => traces.get(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return + const candidate = staged.get(event) + /* v8 ignore next -- internal/dispatch stages every compaction event */ + if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation') + staged.delete(event) + const next = applyCompactionTransition(candidate.transition) + if (next === undefined) traces.delete(session) + else traces.set(session, next) + }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const transition = validateCompactionEvent(traceFor(session), event, fail) + if (transition !== undefined) staged.set(event, { session, transition }) + }, { global: true }) +}, { inject: ['sessions'] }) + /** - * Register this package's invariant companion. + * Register the compact invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 559d46bdc9..1ff03bdfb1 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -38,7 +38,7 @@ class StubCompactService extends CompactService { const summaryEvent = session.append('compact/summary', { summary, shadowedRange: { start, end }, - shadowedSeqs: [], + shadowedSeqs: [start], shadowedTokenCount: 0, provider: 'mock', model: 'stub', @@ -50,7 +50,7 @@ class StubCompactService extends CompactService { endSeq: endEvent.seq, summary, shadowedRange: { start, end }, - shadowedSeqs: [], + shadowedSeqs: [start], shadowedTokenCount: 0, } } diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts new file mode 100644 index 0000000000..2f4d10ff71 --- /dev/null +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(CompactInvariant) + return ctx +} + +const summary = (overrides: Record = {}) => ({ + summary: [{ type: 'text' as const, text: 'short' }], + shadowedRange: { start: 2, end: 4 }, + shadowedSeqs: [2, 3, 4], + shadowedTokenCount: 12, + provider: 'mock', + model: 'mock', + ...overrides, +}) + +describe('compaction invariants', () => { + it('accepts successful and failed compaction lifecycles', async () => { + const ctx = await setup() + const success = ctx.sessions.create() + success.append('compact/start', { turn: 1 }) + success.append('compact/summary', summary()) + success.append('compact/end', { turn: 1 }) + + const failed = ctx.sessions.create() + failed.append('compact/start', { turn: 2 }) + failed.append('compact/end', { turn: 2, error: 'provider failed' }) + }) + + it('rebuilds an open trace when the companion loads after the session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('compact/start', { turn: 3 }) + await ctx.plugin(InvariantService) + await ctx.plugin(CompactInvariant) + expect(() => session.append('compact/end', { turn: 3, error: 'resume failed' })).not.toThrow() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + + it.each([ + ['summary without start', (session: ReturnType) => { + session.append('compact/summary', summary()) + }, /no matching compact\/start/], + ['nested start', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/start', { turn: 2 }) + }, /still compacting/], + ['repeated summary', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/summary', summary()) + session.append('compact/summary', summary()) + }, /repeated within one compaction/], + ['empty shadow set', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/summary', summary({ shadowedSeqs: [] })) + }, /shadowedSeqs must be non-empty/], + ['wrong endpoints', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/summary', summary({ shadowedRange: { start: 1, end: 4 } })) + }, /shadowedRange must match/], + ['invalid token count', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/summary', summary({ shadowedTokenCount: -1 })) + }, /non-negative safe integer/], + ['end without start', (session: ReturnType) => { + session.append('compact/end', { turn: 1, error: 'failed' }) + }, /no matching compact\/start/], + ['wrong end turn', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/end', { turn: 2, error: 'failed' }) + }, /does not match/], + ['success without summary', (session: ReturnType) => { + session.append('compact/start', { turn: 1 }) + session.append('compact/end', { turn: 1 }) + }, /requires one compact\/summary/], + ])('rejects %s', async (_name, action, message) => { + const ctx = await setup() + expect(() => { action(ctx.sessions.create()) }).toThrow(message) + }) +}) diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index f1497f4c4a..804d419627 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -1,30 +1,64 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-time-context`. @module @deepseek-ai/dsh-time-context/invariant */ +/** Package-owned durable clock-context invariants. @module @deepseek-ai/dsh-time-context/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-time-context' +const SOURCE_NAME = 'time-context' +const READING = new RegExp( + '^Time sampled while preparing turn (\\d+), step (\\d+): ' + + '(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\n' + + 'Elapsed since the preceding (model-visible message|step context): ' + + '(?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$', +) /** Cordis companion plugin name. */ export const name = 'time-context-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +/** Validate one plugin-attributed time reading against its durable event timestamp. */ +function validateReading(event: SessionEvent<'context/message'>, fail: InvariantFailure): void { + const [block] = event.data.content + if (event.data.content.length !== 1 || block?.type !== 'text') { + fail('time-context messages must contain exactly one text block') + } + const match = READING.exec(block.text) + if (match === null) fail('time-context message does not match the durable reading format') + const turn = Number(match[1]) + const step = Number(match[2]) + if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) { + fail('time-context turn and step must be positive safe integers') + } + const baseline = match[4] + if ((step === 1) !== (baseline === 'model-visible message')) { + fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`) + } + const rendered = match[3] + /* v8 ignore next -- the preceding fixed regexp always supplies capture group three. */ + if (rendered === undefined) fail('time-context reading omitted its rendered timestamp') + const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, '')) + if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time) + || event.time < renderedTime || event.time - renderedTime >= 1_000) { + fail('time-context rendered timestamp must identify the durable event second') + } +} + +/** Install validation for plugin-attributed context readings. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'time-context', - inject: [ - 'agents', - ], - effects: [ - 'ctx.on("agent/pre-step")', - ], - }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const event = (args as [Session, SessionEvent])[1] + if (event.type !== 'context/message' + || event.data.source.kind !== 'plugin' + || event.data.source.plugin !== SOURCE_NAME) return + validateReading(event, fail) + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the time-context invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts new file mode 100644 index 0000000000..eab422e95e --- /dev/null +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +const SECOND = Date.parse('2026-07-14T00:00:00Z') + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(TimeInvariant) + return ctx +} + +function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent { + return { + type: 'context/message', + seq: 0, + time, + data: { + content: (content ?? [{ type: 'text', text }]) as ContentBlock[], + source: { kind: 'plugin', plugin: 'time-context' }, + }, + } +} + +function reading( + turn = '1', + step = '1', + baseline = 'model-visible message', + timestamp = '2026-07-14T00:00:00+00:00[UTC]', +): string { + return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n` + + `Elapsed since the preceding ${baseline}: unavailable.` +} + +describe('time-context invariants', () => { + it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => { + const ctx = await setup() + const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n' + + 'Elapsed since the preceding step context: 4m 2s.' + expect(() => { ctx.emit('session/event', {} as Session, event(text)) }).not.toThrow() + }) + + it.each([ + ['not a reading', SECOND, undefined, /durable reading format/], + [reading('0'), SECOND, undefined, /positive safe integers/], + [reading('999999999999999999999'), SECOND, undefined, /positive safe integers/], + [reading('1', '0', 'step context'), SECOND, undefined, /positive safe integers/], + [reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/], + [reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/], + [reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/], + [reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /durable event second/], + [reading(), Number.NaN, undefined, /durable event second/], + [reading(), SECOND - 1, undefined, /durable event second/], + [reading(), SECOND + 1_000, undefined, /durable event second/], + ['ignored', SECOND, [], /exactly one text block/], + ['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/], + ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/], + ] as const)('rejects an incoherent durable reading', async (text, time, content, message) => { + const ctx = await setup() + expect(() => { + ctx.emit('session/event', {} as Session, event(text, time, content === undefined ? undefined : [...content])) + }).toThrow(message) + }) + + it('ignores context messages owned by another package', async () => { + const ctx = await setup() + const other = event('unrelated') as SessionEvent<'context/message'> + other.data.source = { kind: 'plugin', plugin: 'other' } + expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + other.data.source = { kind: 'user' } + expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + expect(() => { + ctx.emit('session/event', {} as Session, { + type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) + ctx.emit('tools/change') + }).not.toThrow() + }) +}) diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index 0258acff14..d9f56417b8 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-workspace-context`. @module @deepseek-ai/dsh-workspace-context/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-workspace-context`. + * @module @deepseek-ai/dsh-workspace-context/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context' /** Cordis companion plugin name. */ export const name = 'workspace-context-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'workspace-context', - effects: [ - 'ctx.on("session/event")', - 'ctx.on("agent/session-prefix")', - 'ctx.on("tools/post-execute")', - 'ctx.on("tools/result")', - ], - }) -} +/** + * No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata, + * while focused pipeline tests own its private pending/cache state transitions. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/cordis/tool-cordis/src/invariant.ts b/packages/cordis/tool-cordis/src/invariant.ts index 8d09b3bb9e..6fd73d0353 100644 --- a/packages/cordis/tool-cordis/src/invariant.ts +++ b/packages/cordis/tool-cordis/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-cordis`. @module @deepseek-ai/dsh-tool-cordis/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-cordis`. + * @module @deepseek-ai/dsh-tool-cordis/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis' /** Cordis companion plugin name. */ export const name = 'tool-cordis-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-cordis', - inject: [ - 'tools', - ], - effects: [ - 'ctx.plugin()', - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts index 36052bb90a..e199cc98b4 100644 --- a/packages/core/system-prompt/src/invariant.ts +++ b/packages/core/system-prompt/src/invariant.ts @@ -1,31 +1,50 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-system-prompt`. @module @deepseek-ai/dsh-system-prompt/invariant */ +/** Package-owned prompt-assembly invariants. @module @deepseek-ai/dsh-system-prompt/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { PromptAssembly } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-system-prompt' +const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ /** Cordis companion plugin name. */ export const name = 'system-prompt-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +/** Validate the authoritative assembly returned by the waterfall. */ +function validateAssembly(assembly: PromptAssembly, fail: InvariantFailure): void { + const sectionNames = new Set() + for (const section of assembly.sections) { + if (section.name.length === 0) fail('assembled section names must be non-empty') + if (sectionNames.has(section.name)) fail(`assembled section name ${JSON.stringify(section.name)} is duplicated`) + sectionNames.add(section.name) + if (typeof section.text !== 'string') fail(`assembled section ${JSON.stringify(section.name)} text must be a string`) + } + + for (const tool of assembly.tools) { + if (tool.name.length === 0) fail('assembled tool names must be non-empty') + } + + for (const [name, value] of Object.entries(assembly.variables)) { + if (!VARIABLE_NAME.test(name)) fail(`assembled variable name ${JSON.stringify(name)} is invalid`) + if (value !== undefined && typeof value !== 'string') { + fail(`assembled variable ${JSON.stringify(name)} must be a string or undefined`) + } + } +} + +/** Install validation around the authoritative assembly waterfall result. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SystemPrompt', - effects: [ - 'ctx.provide("systemPrompt")', - 'systemPrompt.section()', - ], - services: [ - 'systemPrompt', - ], - }) + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const assembled = await next() + validateAssembly(assembled, fail) + return assembled + }, { global: true, prepend: true }) } /** - * Register this package's invariant companion. + * Register the system-prompt invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/core/system-prompt/tests/invariant.spec.ts b/packages/core/system-prompt/tests/invariant.spec.ts new file mode 100644 index 0000000000..ce03af0b9f --- /dev/null +++ b/packages/core/system-prompt/tests/invariant.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import * as SystemPromptInvariant from '@deepseek-ai/dsh-system-prompt/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(SystemPromptInvariant) + return ctx +} + +const valid = (): PromptAssembly => ({ + sections: [{ name: 'identity', text: 'prompt' }], + tools: [{ name: 'echo', description: 'Echo', parameters: {} }], + variables: { cwd: '/repo', optional: undefined }, +}) + +async function assemble(ctx: Context, result: PromptAssembly): Promise { + return ctx.waterfall( + ctx as never, 'system-prompt/assemble', valid(), {}, + () => Promise.resolve(result), + ) +} + +describe('system-prompt invariants', () => { + it('accepts a well-formed authoritative assembly', async () => { + const ctx = await setup() + await expect(assemble(ctx, valid())).resolves.toEqual(valid()) + }) + + it.each([ + [{ ...valid(), sections: [{ name: '', text: 'x' }] }, /section names must be non-empty/], + [{ ...valid(), sections: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /section name "x" is duplicated/], + [{ ...valid(), sections: [{ name: 'x', text: 1 as never }] }, /section "x" text must be a string/], + [{ ...valid(), tools: [{ name: '', description: 'x', parameters: {} }] }, /tool names must be non-empty/], + [{ ...valid(), variables: { Bad: 'x' } }, /variable name "Bad" is invalid/], + [{ ...valid(), variables: { value: 1 as never } }, /variable "value" must be a string or undefined/], + ])('rejects malformed authoritative assembly %#', async (assembly, message) => { + const ctx = await setup() + await expect(assemble(ctx, assembly)).rejects.toThrow(message) + }) +}) diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index c5b53e67fa..2f5f27a281 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -1,34 +1,67 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tools`. @module @deepseek-ai/dsh-tools/invariant */ +/** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ToolExecution, ToolExecutionResult } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tools' /** Cordis companion plugin name. */ export const name = 'tools-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +type ToolStage = 'pre' | 'execute' | 'post' + +/** Validate the immutable final execution/result snapshot. */ +function validateResult( + exec: Readonly, + result: Readonly, + fail: InvariantFailure, +): void { + if (!Object.isFrozen(exec)) fail('tools/result execution must be frozen before publication') + if (!Object.isFrozen(result) || !Object.isFrozen(result.content)) { + fail('tools/result outcome and content must be frozen before publication') + } + if (exec.name.length === 0 || String(exec.callId).length === 0) { + fail('tools/result execution must carry non-empty name and callId') + } +} + +/** Install monotonic pipeline and final-snapshot checks. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'ToolRegistry', - inject: [ - 'systemPrompt', - ], - effects: [ - 'ctx.provide("tools")', - 'systemPrompt.tools()', - ], - services: [ - 'tools', - ], - }) + const stages = new WeakMap() + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName === 'tools/pre-execute') { + const exec = args[0] as ToolExecution + if (stages.has(exec)) fail('tools/pre-execute repeated for one execution') + stages.set(exec, 'pre') + return + } + if (eventName === 'tools/execute') { + const exec = args[0] as ToolExecution + if (stages.get(exec) !== 'pre') fail('tools/execute must follow tools/pre-execute') + stages.set(exec, 'execute') + return + } + if (eventName === 'tools/post-execute') { + const exec = args[0] as ToolExecution + const previous = stages.get(exec) + if (previous !== 'pre' && previous !== 'execute') { + fail('tools/post-execute must follow tools/pre-execute or tools/execute') + } + stages.set(exec, 'post') + return + } + if (eventName !== 'tools/result') return + const [exec, result] = args as [Readonly, Readonly] + validateResult(exec, result, fail) + stages.delete(exec) + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the tools invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts new file mode 100644 index 0000000000..3db0e961ce --- /dev/null +++ b/packages/core/tools/tests/invariant.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(ToolsInvariant) + return ctx +} + +const execution = (overrides: Partial = {}): ToolExecution => ({ + token: Symbol('tool') as ToolExecutionToken, + callId: CallId('call-1'), + name: 'echo', + arguments: Object.freeze({ text: 'hi' }), + ...overrides, +}) + +const outcome = (): ToolExecutionResult => Object.freeze({ + content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never, + isError: false, +}) + +function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void { + ctx.emit(scopeTarget(ctx as never, undefined), 'tools/result', exec, result) +} + +async function stage(ctx: Context, name: 'tools/pre-execute' | 'tools/execute', exec: ToolExecution): Promise { + if (name === 'tools/pre-execute') { + await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve({ kind: 'allow' as const })) + } else { + await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve(outcome())) + } +} + +describe('tool-pipeline invariants', () => { + it('accepts dispatch and denial stage orders with frozen results', async () => { + const ctx = await setup() + const dispatched = execution() + await stage(ctx, 'tools/pre-execute', dispatched) + await stage(ctx, 'tools/execute', dispatched) + await ctx.waterfall(ctx as never, 'tools/post-execute', dispatched, outcome(), () => Promise.resolve({ kind: 'accept' as const })) + Object.freeze(dispatched) + emitResult(ctx, dispatched, outcome()) + + const denied = execution({ callId: CallId('call-2') }) + await stage(ctx, 'tools/pre-execute', denied) + await ctx.waterfall(ctx as never, 'tools/post-execute', denied, outcome(), () => Promise.resolve({ kind: 'accept' as const })) + Object.freeze(denied) + emitResult(ctx, denied, outcome()) + ctx.emit('tools/change') + }) + + it('rejects repeated and out-of-order pipeline stages', async () => { + const ctx = await setup() + const exec = execution() + await stage(ctx, 'tools/pre-execute', exec) + await expect(stage(ctx, 'tools/pre-execute', exec)).rejects.toThrow(/repeated/) + + const noPre = execution({ callId: CallId('call-2') }) + await expect(stage(ctx, 'tools/execute', noPre)).rejects.toThrow(/must follow tools\/pre-execute/) + expect(() => ctx.waterfall( + ctx as never, 'tools/post-execute', noPre, outcome(), + () => Promise.resolve({ kind: 'accept' as const }), + )).toThrow(/must follow tools\/pre-execute or tools\/execute/) + }) + + it('rejects mutable or anonymous final snapshots', async () => { + const ctx = await setup() + expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/) + + const exec = Object.freeze(execution()) + expect(() => { emitResult(ctx, exec, { content: [], isError: false }) }) + .toThrow(/outcome and content must be frozen/) + + const anonymous = Object.freeze(execution({ name: '' })) + expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/) + }) +}) diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts index b8801dd71e..95b57b57e1 100644 --- a/packages/examples/acp-demo/src/invariant.ts +++ b/packages/examples/acp-demo/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-acp-demo`. @module @deepseek-ai/dsh-acp-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-acp-demo`. + * @module @deepseek-ai/dsh-acp-demo/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-demo' /** Cordis companion plugin name. */ export const name = 'acp-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'acp-demo', - effects: [ - 'ctx.plugin()', - ], - }) -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts index 75ab162567..fada985329 100644 --- a/packages/examples/agent-spine-demo/src/invariant.ts +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-agent-spine-demo`. @module @deepseek-ai/dsh-agent-spine-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-spine-demo`. + * @module @deepseek-ai/dsh-agent-spine-demo/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-spine-demo' /** Cordis companion plugin name. */ export const name = 'agent-spine-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'agent-spine-demo', - effects: [ - 'ctx.plugin()', - ], - }) -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/src/invariant.ts b/packages/examples/cli-demo/src/invariant.ts index 016c0fd18f..8eb40e9268 100644 --- a/packages/examples/cli-demo/src/invariant.ts +++ b/packages/examples/cli-demo/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-cli-demo`. @module @deepseek-ai/dsh-cli-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-cli-demo`. + * @module @deepseek-ai/dsh-cli-demo/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-cli-demo' /** Cordis companion plugin name. */ export const name = 'cli-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'cli-demo', - effects: [ - 'ctx.plugin()', - ], - }) -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index a9352d02f8..dd093a5418 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -1,22 +1,24 @@ -/** Package-owned runtime contract for @deepseek-ai/dsh-jsonrpc-demo. @module @deepseek-ai/dsh-jsonrpc-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-jsonrpc-demo`. + * @module @deepseek-ai/dsh-jsonrpc-demo/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc-demo' /** Cordis companion plugin name. */ export const name = 'jsonrpc-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert that Loader configuration, rather than a hidden root plugin, owns composition. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const packageEntry = await import('./index.ts') - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/examples/stdio-demo/src/invariant.ts b/packages/examples/stdio-demo/src/invariant.ts index 8e24a3b94b..125d01347c 100644 --- a/packages/examples/stdio-demo/src/invariant.ts +++ b/packages/examples/stdio-demo/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-stdio-demo`. @module @deepseek-ai/dsh-stdio-demo/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-stdio-demo`. + * @module @deepseek-ai/dsh-stdio-demo/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-stdio-demo' /** Cordis companion plugin name. */ export const name = 'stdio-demo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'stdio-demo', - effects: [ - 'ctx.plugin()', - ], - }) -} +/** + * No runtime invariant: this composition package owns no independent event stream or mutable data; + * Loader and built-entry tests cover its wiring. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts index 6469bffae6..3e38550065 100644 --- a/packages/fs/fs-local/src/invariant.ts +++ b/packages/fs/fs-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs-local`. @module @deepseek-ai/dsh-fs-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-fs-local`. + * @module @deepseek-ai/dsh-fs-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-local' /** Cordis companion plugin name. */ export const name = 'fs-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LocalFileSystem', - effects: [ - 'ctx.provide("fs")', - ], - services: [ - 'fs', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts index de77ac303d..369fa5ea84 100644 --- a/packages/fs/fs-policy/src/invariant.ts +++ b/packages/fs/fs-policy/src/invariant.ts @@ -1,26 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs-policy`. @module @deepseek-ai/dsh-fs-policy/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-fs-policy`. + * @module @deepseek-ai/dsh-fs-policy/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-policy' /** Cordis companion plugin name. */ export const name = 'fs-policy-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'fs-policy', - effects: [ - 'ctx.on("fs/write-intent")', - 'ctx.on("fs/edit-intent")', - 'ctx.on("fs/observed")', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -29,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts index 586f962247..429c1cec41 100644 --- a/packages/fs/fs/src/invariant.ts +++ b/packages/fs/fs/src/invariant.ts @@ -1,24 +1,37 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-fs`. @module @deepseek-ai/dsh-fs/invariant */ +/** Package-owned filesystem event-data invariants. @module @deepseek-ai/dsh-fs/invariant */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { FsTarget, FsVersion } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-fs' /** Cordis companion plugin name. */ export const name = 'fs-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ +/** Assert that an event carries a usable opaque target identity. */ +function validateTarget(target: FsTarget, fail: (message: string) => never): void { + if (target.targetKey.length === 0) fail('filesystem event targetKey must be non-empty') + if (target.displayPath.length === 0) fail('filesystem event displayPath must be non-empty') +} + +/** Install checks over the filesystem decision and observation event stream. */ const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'fs', value => serviceShapeViolation(value, { - methods: ['resolve', 'stat', 'lstat', 'readText', 'streamText', 'listDir', 'writeText', 'editText'], - })) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'fs/write-intent' + && eventName !== 'fs/edit-intent' + && eventName !== 'fs/observed') return + validateTarget(args[0] as FsTarget, fail) + if (eventName === 'fs/observed' && (args[1] as FsVersion).length === 0) { + fail('fs/observed version must be non-empty') + } + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the filesystem invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/fs/fs/tests/invariant.spec.ts b/packages/fs/fs/tests/invariant.spec.ts new file mode 100644 index 0000000000..c160e3d238 --- /dev/null +++ b/packages/fs/fs/tests/invariant.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { FsTarget } from '@deepseek-ai/dsh-fs' +import * as FsInvariant from '@deepseek-ai/dsh-fs/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(FsInvariant) + return ctx +} + +const target = (key = 'file:1', displayPath = 'file.txt'): FsTarget => ({ + targetKey: FsTargetKey(key), + displayPath, +}) + +describe('filesystem invariants', () => { + it('accepts decision and observation events with usable identities', async () => { + const ctx = await setup() + await expect(ctx.waterfall( + ctx as never, 'fs/write-intent', target(), undefined, + () => Promise.resolve(undefined), + )).resolves.toBeUndefined() + await expect(ctx.waterfall( + ctx as never, 'fs/edit-intent', target(), undefined, + () => Promise.resolve(undefined), + )).resolves.toBeUndefined() + expect(() => { ctx.emit('fs/observed', target(), FsVersion('v1'), undefined) }).not.toThrow() + expect(() => { ctx.emit('tools/change') }).not.toThrow() + }) + + it('rejects empty target and version identities', async () => { + const ctx = await setup() + expect(() => { ctx.emit('fs/observed', target(''), FsVersion('v1'), undefined) }) + .toThrow(/targetKey must be non-empty/) + expect(() => { ctx.emit('fs/observed', target('file:1', ''), FsVersion('v1'), undefined) }) + .toThrow(/displayPath must be non-empty/) + expect(() => { ctx.emit('fs/observed', target(), FsVersion(''), undefined) }) + .toThrow(/version must be non-empty/) + }) +}) diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts index 65da611560..f7f206896d 100644 --- a/packages/fs/tool-fs-search/src/invariant.ts +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-fs-search`. @module @deepseek-ai/dsh-tool-fs-search/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-fs-search`. + * @module @deepseek-ai/dsh-tool-fs-search/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs-search' /** Cordis companion plugin name. */ export const name = 'tool-fs-search-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-fs-search', - inject: [ - 'tools', - 'systemPrompt', - 'bash', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts index caa31a13b5..eaa2485c06 100644 --- a/packages/fs/tool-fs/src/invariant.ts +++ b/packages/fs/tool-fs/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-fs`. @module @deepseek-ai/dsh-tool-fs/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-fs`. + * @module @deepseek-ai/dsh-tool-fs/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs' /** Cordis companion plugin name. */ export const name = 'tool-fs-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-fs', - inject: [ - 'tools', - 'fs', - 'systemPrompt', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts index 6bbdd1b539..5d8544b9aa 100644 --- a/packages/guard/repeat-tool-guard/src/invariant.ts +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -1,25 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-repeat-tool-guard`. @module @deepseek-ai/dsh-repeat-tool-guard/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-repeat-tool-guard`. + * @module @deepseek-ai/dsh-repeat-tool-guard/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-repeat-tool-guard' /** Cordis companion plugin name. */ export const name = 'repeat-tool-guard-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'repeat-tool-guard', - effects: [ - 'ctx.on("tools/post-execute")', - 'ctx.on("agent/prompt-submit")', - ], - }) -} +/** + * No runtime invariant: the repeat chain is private to one post-execute listener and exposes no + * package-owned event or snapshot that an independent companion can observe. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -28,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 997dd75b30..73d6f08e3b 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -1,39 +1,98 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-hook-protocol. @module @deepseek-ai/dsh-hook-protocol/invariant */ +/** Package-owned hook provenance-stream invariants. @module @deepseek-ai/dsh-hook-protocol/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type {} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-hook-protocol' /** Cordis companion plugin name. */ export const name = 'hook-protocol-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert blocking-exit decoding and restrictive merge precedence. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const [{ parseHookOutput }, { mergeHookOutputs }] = await Promise.all([ - import('./codec.ts'), - import('./merge.ts'), - ]) - const blocked = parseHookOutput(2, '', ' denied ') - assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', - 'exit 2 must decode as a block whose reason is trimmed stderr') - - const merged = mergeHookOutputs([ - { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, - { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, - ]) - assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', - 'deny must override allow and retain only the winning decision reason') +interface HookTransition { + key: string + delta: 1 | -1 } +/** Correlation key shared by an invoked/result pair. */ +function hookKey(data: { turn: number; point: string; handlerId: string }): string { + return `${data.turn}\0${data.point}\0${data.handlerId}` +} + +/** Validate one hook event against committed pending invocations. */ +function validateHookEvent( + pending: ReadonlyMap, + event: SessionEvent, + fail: InvariantFailure, +): HookTransition | undefined { + if (event.type === 'hook/invoked') { + if (event.data.point.length === 0 || event.data.handlerId.length === 0) { + fail('hook/invoked point and handlerId must be non-empty') + } + const dialect: string = event.data.dialect + if (dialect !== 'claude' && dialect !== 'codex') { + fail(`hook/invoked carries unknown dialect ${JSON.stringify(dialect)}`) + } + return { key: hookKey(event.data), delta: 1 } + } + if (event.type !== 'hook/result') return undefined + const key = hookKey(event.data) + if ((pending.get(key) ?? 0) === 0) { + fail(`hook/result has no matching hook/invoked for ${JSON.stringify(event.data.handlerId)}`) + } + if (!Number.isFinite(event.data.durationMs) || event.data.durationMs < 0) { + fail('hook/result durationMs must be a non-negative finite number') + } + return { key, delta: -1 } +} + +/** Apply one committed hook-pair transition. */ +function applyHookTransition(pending: Map, transition: HookTransition): void { + const next = (pending.get(transition.key) ?? 0) + transition.delta + if (next === 0) pending.delete(transition.key) + else pending.set(transition.key, next) +} + +/** Install hook invoked/result pairing checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap>() + const staged = new WeakMap() + const seed = (session: Session): Map => { + const pending = new Map() + traces.set(session, pending) + for (const event of session.events) { + const transition = validateHookEvent(pending, event, fail) + if (transition !== undefined) applyHookTransition(pending, transition) + } + return pending + } + const traceFor = (session: Session): Map => traces.get(session) ?? seed(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'hook/invoked' && event.type !== 'hook/result') return + const candidate = staged.get(event) + /* v8 ignore next -- internal/dispatch stages every hook provenance event */ + if (candidate === undefined || candidate.session !== session) return fail('hook event published without pre-commit validation') + staged.delete(event) + applyHookTransition(traceFor(session), candidate.transition) + }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const transition = validateHookEvent(traceFor(session), event, fail) + if (transition !== undefined) staged.set(event, { session, transition }) + }, { global: true }) +}, { inject: ['sessions'] }) + /** - * Register this package's invariant companion. + * Register the hook-protocol invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts new file mode 100644 index 0000000000..dc7b1d38bb --- /dev/null +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import * as HookInvariant from '@deepseek-ai/dsh-hook-protocol/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(HookInvariant) + return ctx +} + +const invoked = (overrides: Record = {}) => ({ + turn: 1, + point: 'PreToolUse', + dialect: 'claude' as const, + handlerId: 'hook-1', + ...overrides, +}) + +const result = (overrides: Record = {}) => ({ + turn: 1, + point: 'PreToolUse', + handlerId: 'hook-1', + decision: 'pass', + durationMs: 3, + ...overrides, +}) + +describe('hook-protocol invariants', () => { + it('pairs serial and repeated handler invocations', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + session.append('hook/invoked', invoked()) + session.append('hook/invoked', invoked()) + session.append('hook/result', result()) + session.append('hook/result', result()) + }) + + it('rebuilds pending hook provenance from an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('hook/invoked', invoked()) + await ctx.plugin(InvariantService) + await ctx.plugin(HookInvariant) + expect(() => session.append('hook/result', result())).not.toThrow() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + + it('adopts a bare session first observed through publication', async () => { + const ctx = await setup() + const session = new Session(SessionId('bare-hook-session')) + expect(() => { + ctx.emit('session/event', session, { + type: 'hook/invoked', seq: 0, time: 0, data: invoked(), + }) + ctx.emit('session/event', session, { + type: 'hook/result', seq: 1, time: 1, data: result(), + }) + }).not.toThrow() + }) + + it.each([ + [invoked({ point: '' }), /point and handlerId must be non-empty/], + [invoked({ handlerId: '' }), /point and handlerId must be non-empty/], + [invoked({ dialect: 'other' }), /unknown dialect/], + ])('rejects malformed hook invocation %#', async (data, message) => { + const ctx = await setup() + expect(() => ctx.sessions.create().append('hook/invoked', data as never)).toThrow(message) + }) + + it('rejects unmatched and malformed results', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('hook/result', result())).toThrow(/no matching hook\/invoked/) + session.append('hook/invoked', invoked()) + expect(() => session.append('hook/result', result({ durationMs: -1 }))) + .toThrow(/durationMs must be a non-negative finite number/) + expect(() => session.append('hook/result', result({ point: 'Stop' }))) + .toThrow(/no matching hook\/invoked/) + }) +}) diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts index d092d78a00..18bc942e9f 100644 --- a/packages/hooks/hooks-claude/src/invariant.ts +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -1,40 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-hooks-claude`. @module @deepseek-ai/dsh-hooks-claude/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-hooks-claude`. + * @module @deepseek-ai/dsh-hooks-claude/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude' /** Cordis companion plugin name. */ export const name = 'hooks-claude-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'hooks-claude', - inject: [ - 'bash', - ], - validate: (_fiber, effectLabels) => { - const hookEffects = [ - 'hooks-claude: drain detached hook runs', - 'ctx.on("agent/session-start")', - 'ctx.on("agent/prompt-submit")', - 'ctx.on("tools/pre-execute")', - 'ctx.on("tools/post-execute")', - 'ctx.on("agent/turn-continuation")', - 'ctx.on("subagent/start")', - 'ctx.on("subagent/end")', - ] - const installed = hookEffects.filter(label => effectLabels.has(label)).length - return installed === 0 || installed === hookEffects.length - ? undefined - : 'a readable Claude hook config must install its complete listener set atomically' - }, - }) -} +/** + * No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns + * their cross-event provenance relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -43,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-claude/tests/invariant.spec.ts b/packages/hooks/hooks-claude/tests/invariant.spec.ts deleted file mode 100644 index 1f7513cc64..0000000000 --- a/packages/hooks/hooks-claude/tests/invariant.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import type { BashExecutor } from '@deepseek-ai/dsh-bash' - -describe('Claude hook package invariant', () => { - it('rejects a partially installed hook listener set', async () => { - const ctx = new Context() - await ctx.plugin({ - name: 'claude-invariant-bash', - apply(child: Context) { - child.provide('bash', { - resolve() {}, - async run() {}, - start() {}, - } as unknown as BashExecutor) - }, - }) - await expect(ctx.plugin({ - name: 'hooks-claude', - inject: ['bash'], - apply(child: Context) { - child.effect(() => () => {}, 'ctx.on("agent/session-start")') - }, - })).rejects.toThrow(/must install its complete listener set atomically/) - }) -}) diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts index 4fba6cea2d..5f0f6ed173 100644 --- a/packages/hooks/hooks-codex/src/invariant.ts +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -1,38 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-hooks-codex`. @module @deepseek-ai/dsh-hooks-codex/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-hooks-codex`. + * @module @deepseek-ai/dsh-hooks-codex/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-codex' /** Cordis companion plugin name. */ export const name = 'hooks-codex-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'hooks-codex', - inject: [ - 'bash', - ], - validate: (_fiber, effectLabels) => { - const hookEffects = [ - 'hooks-codex: drain detached hook runs', - 'ctx.on("agent/session-start")', - 'ctx.on("agent/prompt-submit")', - 'ctx.on("tools/pre-execute")', - 'ctx.on("tools/post-execute")', - 'ctx.on("agent/turn-continuation")', - ] - const installed = hookEffects.filter(label => effectLabels.has(label)).length - return installed === 0 || installed === hookEffects.length - ? undefined - : 'a readable Codex hook config must install its complete listener set atomically' - }, - }) -} +/** + * No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns + * their cross-event provenance relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -41,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-codex/tests/invariant.spec.ts b/packages/hooks/hooks-codex/tests/invariant.spec.ts deleted file mode 100644 index bd6ee595f9..0000000000 --- a/packages/hooks/hooks-codex/tests/invariant.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import type { BashExecutor } from '@deepseek-ai/dsh-bash' - -describe('Codex hook package invariant', () => { - it('rejects a partially installed hook listener set', async () => { - const ctx = new Context() - await ctx.plugin({ - name: 'codex-invariant-bash', - apply(child: Context) { - child.provide('bash', { - resolve() {}, - async run() {}, - start() {}, - } as unknown as BashExecutor) - }, - }) - await expect(ctx.plugin({ - name: 'hooks-codex', - inject: ['bash'], - apply(child: Context) { - child.effect(() => () => {}, 'ctx.on("agent/session-start")') - }, - })).rejects.toThrow(/must install its complete listener set atomically/) - }) -}) diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts index 3327cec448..dd2df6e99c 100644 --- a/packages/llm/llm-deepseek/src/invariant.ts +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-deepseek`. @module @deepseek-ai/dsh-llm-deepseek/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-llm-deepseek`. + * @module @deepseek-ai/dsh-llm-deepseek/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-deepseek' /** Cordis companion plugin name. */ export const name = 'llm-deepseek-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'llm-deepseek', - inject: [ - 'llm', - ], - effects: [ - 'llm.registerAdapter()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts index ba546fb902..a096804fd2 100644 --- a/packages/llm/llm-pi-ai/src/invariant.ts +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-pi-ai`. @module @deepseek-ai/dsh-llm-pi-ai/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-llm-pi-ai`. + * @module @deepseek-ai/dsh-llm-pi-ai/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-pi-ai' /** Cordis companion plugin name. */ export const name = 'llm-pi-ai-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'llm-pi-ai', - inject: [ - 'llm', - ], - effects: [ - 'llm.registerAdapter()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index a4b8b50a3f..76d55509cb 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -1,30 +1,93 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm`. @module @deepseek-ai/dsh-llm/invariant */ +/** Package-owned LLM stream-protocol invariants. @module @deepseek-ai/dsh-llm/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlockType, StreamChunk } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-llm' /** Cordis companion plugin name. */ export const name = 'llm-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +/** Require one chunk index to be a non-negative safe integer. */ +function validateIndex(index: number, fail: InvariantFailure): void { + if (!Number.isSafeInteger(index) || index < 0) { + fail(`LLM stream block index must be a non-negative safe integer, got ${index}`) + } +} + +/** Require a delta to address an open block of its matching type. */ +function validateDelta( + open: ReadonlyMap, + index: number, + expected: ContentBlockType, + fail: InvariantFailure, +): void { + validateIndex(index, fail) + const actual = open.get(index) + if (actual !== expected) { + fail(`${expected} delta at index ${index} requires an open ${expected} block, got ${String(actual)}`) + } +} + +/** Wrap one provider stream and enforce its grammar as chunks are consumed. */ +async function* validateStream( + source: AsyncIterable, + fail: InvariantFailure, +): AsyncIterable { + const open = new Map() + let usageSeen = false + let finished = false + for await (const chunk of source) { + if (finished) fail(`LLM stream emitted ${chunk.type} after terminal finish`) + switch (chunk.type) { + case 'block-start': + validateIndex(chunk.index, fail) + if (open.has(chunk.index)) fail(`LLM stream repeated block-start index ${chunk.index}`) + open.set(chunk.index, chunk.blockType) + break + case 'text-delta': + validateDelta(open, chunk.index, 'text', fail) + break + case 'reasoning-delta': + validateDelta(open, chunk.index, 'reasoning', fail) + break + case 'tool-call-delta': + validateDelta(open, chunk.index, 'tool-call', fail) + break + case 'block-end': { + validateIndex(chunk.index, fail) + const blockType = open.get(chunk.index) + if (blockType === undefined) fail(`LLM stream block-end index ${chunk.index} has no open block`) + if (chunk.block.type !== blockType) { + fail(`LLM stream block-end index ${chunk.index} closes ${chunk.block.type}, expected ${blockType}`) + } + open.delete(chunk.index) + break + } + case 'usage': + if (usageSeen) fail('LLM stream emitted usage more than once') + usageSeen = true + break + case 'finish': + if (open.size > 0) fail(`LLM stream finished with ${open.size} open block(s)`) + finished = true + break + } + yield chunk + } + if (!finished) fail('LLM stream ended without a terminal finish chunk') +} + +/** Install validation around every provider stream. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LlmService', - effects: [ - 'ctx.provide("llm")', - ], - services: [ - 'llm', - ], - }) + ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true }) } /** - * Register this package's invariant companion. + * Register the LLM invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/llm/llm/tests/invariant.spec.ts b/packages/llm/llm/tests/invariant.spec.ts new file mode 100644 index 0000000000..9eb868df1c --- /dev/null +++ b/packages/llm/llm/tests/invariant.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import * as LlmInvariant from '@deepseek-ai/dsh-llm/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(LlmInvariant) + return ctx +} + +const options: GenerateOptions = { provider: 'mock', model: 'mock', messages: [] } + +async function* source(chunks: readonly StreamChunk[]): AsyncIterable { + yield* chunks +} + +async function consume(ctx: Context, chunks: readonly StreamChunk[]): Promise { + const stream = ctx.waterfall(ctx as never, 'llm/stream', options, () => source(chunks)) + const consumed: StreamChunk[] = [] + for await (const chunk of stream) consumed.push(chunk) + return consumed +} + +const finish: StreamChunk = { type: 'finish', reason: { kind: 'stop' } } + +describe('LLM stream invariants', () => { + it('accepts a complete interleaved stream grammar', async () => { + const ctx = await setup() + const chunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'a' }, + { type: 'block-start', index: 1, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 1, text: 'b' }, + { type: 'block-end', index: 1, block: { type: 'reasoning', text: 'b' } }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'a' } }, + { type: 'block-start', index: 2, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 2, id: CallId('c1'), name: 'echo', argumentsDelta: '{}' }, + { type: 'block-end', index: 2, block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }, + finish, + ] + await expect(consume(ctx, chunks)).resolves.toEqual(chunks) + }) + + it.each([ + [[{ type: 'block-start', index: -1, blockType: 'text' }, finish], /non-negative safe integer/], + [[ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-start', index: 0, blockType: 'text' }, + ], /repeated block-start/], + [[{ type: 'text-delta', index: 0, text: 'x' }], /requires an open text block/], + [[ + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'text-delta', index: 0, text: 'x' }, + ], /got reasoning/], + [[{ type: 'block-end', index: 0, block: { type: 'text', text: '' } }], /has no open block/], + [[ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'reasoning', text: '' } }, + ], /closes reasoning, expected text/], + [[ + { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }, + { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }, + ], /usage more than once/], + [[{ type: 'block-start', index: 0, blockType: 'text' }, finish], /finished with 1 open block/], + [[finish, { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }], /usage after terminal finish/], + [[], /ended without a terminal finish/], + ] as Array<[StreamChunk[], RegExp]>)('rejects malformed stream %#', async (chunks, message) => { + const ctx = await setup() + await expect(consume(ctx, chunks)).rejects.toThrow(message) + }) + + it('preserves provider exceptions without inventing a missing-finish failure', async () => { + const ctx = await setup() + const stream = ctx.waterfall(ctx as never, 'llm/stream', options, async function* () { + throw new Error('provider failed') + }) + await expect((async () => { + for await (const _chunk of stream) { /* consume */ } + })()).rejects.toThrow('provider failed') + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 6e14d749ba..9a4345a4ad 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -60,6 +60,7 @@ class CatalogAdapter extends ScriptedAdapter { const SCRIPT: StreamChunk[] = [ { type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text: 'hi' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }, { type: 'finish', reason: { kind: 'stop' } }, ] @@ -489,13 +490,14 @@ describe('LlmService', () => { const inner = next() return (async function * () { yield { type: 'block-start', index: 99, blockType: 'text' } satisfies StreamChunk + yield { type: 'block-end', index: 99, block: { type: 'text', text: '' } } satisfies StreamChunk yield * inner })() }) const chunks: StreamChunk[] = [] for await (const chunk of ctx.llm.stream({ provider: 'test-model', model: 'dynamic-model', messages: [] })) chunks.push(chunk) - expect(chunks).toHaveLength(4) + expect(chunks).toHaveLength(6) expect(chunks[0]).toMatchObject({ index: 99 }) }) diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index 838a9409e8..b8f0cc385a 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-token-meter`. @module @deepseek-ai/dsh-token-meter/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-token-meter`. + * @module @deepseek-ai/dsh-token-meter/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-token-meter' /** Cordis companion plugin name. */ export const name = 'token-meter-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'TokenMeterService', - effects: [ - 'ctx.provide("tokenMeter")', - 'ctx.on("session/event")', - ], - services: [ - 'tokenMeter', - ], - }) -} +/** + * No runtime invariant: token estimates are per-call outputs and the private session cache is + * invalidated at its event mutation boundary; neither exposes an independent observation stream. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts index 3cb9dd6005..e2d8ac22cb 100644 --- a/packages/mcp/mcp-client/src/invariant.ts +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-mcp-client`. @module @deepseek-ai/dsh-mcp-client/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-mcp-client`. + * @module @deepseek-ai/dsh-mcp-client/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-mcp-client' /** Cordis companion plugin name. */ export const name = 'mcp-client-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'mcp-client', - inject: [ - 'tools', - ], - effects: [ - 'mcp-client.serverName', - 'mcp-client.connection', - ], - }) -} +/** + * No runtime invariant: MCP generations contribute through the tool registry, but the bridge + * exposes no independent server-to-tool snapshot after an asynchronous resync. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts index da6897f755..e990d46acc 100644 --- a/packages/sandbox/sandbox-local/src/invariant.ts +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-sandbox-local`. @module @deepseek-ai/dsh-sandbox-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-local`. + * @module @deepseek-ai/dsh-sandbox-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-local' /** Cordis companion plugin name. */ export const name = 'sandbox-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LocalSandboxProvider', - effects: [ - 'ctx.provide("sandbox")', - ], - services: [ - 'sandbox', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts index cd1f767df0..7ee5be733f 100644 --- a/packages/sandbox/sandbox/src/invariant.ts +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -1,21 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-sandbox`. @module @deepseek-ai/dsh-sandbox/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox`. + * @module @deepseek-ai/dsh-sandbox/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox' /** Cordis companion plugin name. */ export const name = 'sandbox-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'sandbox', value => serviceShapeViolation(value, { - methods: ['confine'], - })) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -24,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox/tests/invariant.spec.ts b/packages/sandbox/sandbox/tests/invariant.spec.ts deleted file mode 100644 index 24854b56ae..0000000000 --- a/packages/sandbox/sandbox/tests/invariant.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { InvariantError } from '@deepseek-ai/dsh-invariants' -import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' - -class StubSandboxProvider extends SandboxProvider { - confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { - return { - argv: [...argv], - enforcement: 'full', - denialSignatures: [], - runnerFailureSignatures: [], - } - } -} - -describe('sandbox package invariant', () => { - it('accepts a provider that exposes the confinement seam', async () => { - const ctx = new Context() - await ctx.plugin(StubSandboxProvider) - expect(ctx.sandbox).toBeInstanceOf(StubSandboxProvider) - }) - - it('rejects a service binding without confine()', async () => { - const ctx = new Context() - const invalidSandbox = { - name: 'invalid-sandbox', - apply(child: Context) { - child.provide('sandbox', {} as SandboxProvider) - }, - } - let caught: unknown - try { - await ctx.plugin(invalidSandbox) - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(InvariantError) - expect(caught).toHaveProperty('packageName', '@deepseek-ai/dsh-sandbox') - expect((caught as Error).message).toMatch(/must expose method "confine"/) - }) -}) diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts index 417d936128..368b46fc70 100644 --- a/packages/sdk/create-sdk/src/invariant.ts +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -1,35 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/create-sdk. @module @deepseek-ai/create-sdk/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/create-sdk`. + * @module @deepseek-ai/create-sdk/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/create-sdk' /** Cordis companion plugin name. */ export const name = 'create-sdk-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the bin-only entrypoint and its core argument mapping. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const [{ parseCreateArgs }, packageEntry] = await Promise.all([ - import('./args.ts'), - import('./index.ts'), - ]) - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the create-sdk library entrypoint must remain empty because the package is bin-only') - const parsed = parseCreateArgs([ - 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', - ]) - assertInvariant(fail, - parsed.directory === 'workspace' - && parsed.provider === 'custom' - && parsed.baseURL === 'https://example.test' - && parsed.runInterface === 'embed' - && parsed.install === false, - 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') -} +/** + * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; + * generated output and consumer tests cover its contract. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts index 5faf1ea49c..9185ac8867 100644 --- a/packages/sdk/helper/src/invariant.ts +++ b/packages/sdk/helper/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-helper. @module @deepseek-ai/dsh-helper/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-helper`. + * @module @deepseek-ai/dsh-helper/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-helper' /** Cordis companion plugin name. */ export const name = 'helper-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert FeatureId's zero-cost representation and boundary validation. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { featureId } = await import('./ids.ts') - assertInvariant(fail, featureId('local-plugin') === 'local-plugin', - 'a valid feature id must preserve its runtime string value') - let rejected = false - try { - featureId('Invalid Feature') - } catch (error) { - rejected = error instanceof Error - } - assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') -} +/** + * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; + * generated output and consumer tests cover its contract. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts index 522791fe3b..72e97f3628 100644 --- a/packages/sdk/scripts/src/invariant.ts +++ b/packages/sdk/scripts/src/invariant.ts @@ -1,31 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-scripts. @module @deepseek-ai/dsh-scripts/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-scripts`. + * @module @deepseek-ai/dsh-scripts/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' /** Cordis companion plugin name. */ export const name = 'scripts-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the launcher's opaque post-separator forwarding boundary. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { splitForwardedArgs } = await import('./forwarding.ts') - const plain = splitForwardedArgs(['dev', 'src/index.ts']) - const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) - assertInvariant(fail, - plain.launcher.length === 2 - && plain.forwarded.length === 0 - && separated.launcher.length === 2 - && separated.launcher[1] === 'src/index.ts' - && separated.forwarded.length === 2 - && separated.forwarded[0] === '--inspect' - && separated.forwarded[1] === '9229', - 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') -} +/** + * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; + * generated output and consumer tests cover its contract. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/sdk/telemetry/src/invariant.ts b/packages/sdk/telemetry/src/invariant.ts index 9ec704b672..c3676a1384 100644 --- a/packages/sdk/telemetry/src/invariant.ts +++ b/packages/sdk/telemetry/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-telemetry. @module @deepseek-ai/dsh-telemetry/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-telemetry`. + * @module @deepseek-ai/dsh-telemetry/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-telemetry' /** Cordis companion plugin name. */ export const name = 'telemetry-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the final telemetry redaction boundary removes secrets without corrupting ordinary package metadata. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const [{ telemetryRedactionViolation }, { DEFAULT_REDACTION_PLACEHOLDER, SecretRedactor }] = await Promise.all([ - import('./redaction-contract.ts'), - import('./secret-redactor.ts'), - ]) - const redactor = new SecretRedactor() - const violation = telemetryRedactionViolation(redactor, DEFAULT_REDACTION_PLACEHOLDER, PACKAGE_NAME) - assertInvariant(fail, - violation === undefined, - violation ?? 'telemetry redaction contract failed without a diagnostic') -} +/** + * No runtime invariant: this SDK build-time package owns no live event stream or mutable data; + * generated output and consumer tests cover its contract. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts index a3f3854cff..94d7c2b494 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/invariant.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts @@ -1,33 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence-jsonl`. + * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence-jsonl`. * @module @deepseek-ai/dsh-session-persistence-jsonl/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-jsonl' /** Cordis companion plugin name. */ export const name = 'session-persistence-jsonl-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SessionPersistenceJsonl', - inject: [ - 'sessions', - ], - effects: [ - 'ctx.provide("sessionPersistence")', - ], - services: [ - 'sessionPersistence', - ], - }) -} +/** + * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests; + * this package exposes no continuously observable in-process relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -36,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts index 8eb41c061b..9d841a053d 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/invariant.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts @@ -1,33 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence-sqlite`. + * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence-sqlite`. * @module @deepseek-ai/dsh-session-persistence-sqlite/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite' /** Cordis companion plugin name. */ export const name = 'session-persistence-sqlite-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SessionPersistenceSqlite', - inject: [ - 'sessions', - ], - effects: [ - 'ctx.provide("sessionPersistence")', - ], - services: [ - 'sessionPersistence', - ], - }) -} +/** + * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests; + * this package exposes no continuously observable in-process relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -36,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-persistence/session-persistence/src/invariant.ts b/packages/session-persistence/session-persistence/src/invariant.ts index c62805d300..316774f3fd 100644 --- a/packages/session-persistence/session-persistence/src/invariant.ts +++ b/packages/session-persistence/session-persistence/src/invariant.ts @@ -1,24 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-session-persistence`. + * Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence`. * @module @deepseek-ai/dsh-session-persistence/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence' /** Cordis companion plugin name. */ export const name = 'session-persistence-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'sessionPersistence', value => serviceShapeViolation(value, { - methods: ['locate', 'create', 'append', 'load', 'list'], - })) -} +/** + * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests; + * this package exposes no continuously observable in-process relation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -27,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts index 0f7a93acde..d087dd2378 100644 --- a/packages/session-query/session-query/src/invariant.ts +++ b/packages/session-query/session-query/src/invariant.ts @@ -1,30 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-session-query`. @module @deepseek-ai/dsh-session-query/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-query`. + * @module @deepseek-ai/dsh-session-query/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-query' /** Cordis companion plugin name. */ export const name = 'session-query-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SessionQueryService', - inject: [ - 'sessions', - ], - effects: [ - 'ctx.provide("sessionQuery")', - ], - services: [ - 'sessionQuery', - ], - }) -} +/** + * No runtime invariant: query results are immutable per-call projections whose lineage and event + * relations are validated while they are built; the service retains no observable result state. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/skill-local/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts index 7afa8c5023..6d4917a4d9 100644 --- a/packages/skill/skill-local/src/invariant.ts +++ b/packages/skill/skill-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-skill-local`. @module @deepseek-ai/dsh-skill-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-skill-local`. + * @module @deepseek-ai/dsh-skill-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill-local' /** Cordis companion plugin name. */ export const name = 'skill-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'skill-local', - inject: [ - 'skills', - ], - effects: [ - 'skills.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts index 6a673db92a..5145dee6da 100644 --- a/packages/skill/skill/src/invariant.ts +++ b/packages/skill/skill/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-skill`. @module @deepseek-ai/dsh-skill/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-skill`. + * @module @deepseek-ai/dsh-skill/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill' /** Cordis companion plugin name. */ export const name = 'skill-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SkillService', - effects: [ - 'ctx.provide("skills")', - ], - services: [ - 'skills', - ], - }) -} +/** + * No runtime invariant: provider/runtime maps and revisioned caches mutate atomically inside the + * registry, which exposes no independent change event or snapshot for cross-checking them. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts index 8e6d6bbc3c..68d70fa2d2 100644 --- a/packages/skill/tool-skill/src/invariant.ts +++ b/packages/skill/tool-skill/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-skill`. @module @deepseek-ai/dsh-tool-skill/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-skill`. + * @module @deepseek-ai/dsh-tool-skill/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-skill' /** Cordis companion plugin name. */ export const name = 'tool-skill-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-skill', - inject: [ - 'tools', - 'skills', - ], - effects: [ - 'tools.register()', - 'ctx.on("agent/session-prefix")', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts index b769007d7d..4b44ddbebf 100644 --- a/packages/spill/spill-local/src/invariant.ts +++ b/packages/spill/spill-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill-local`. @module @deepseek-ai/dsh-spill-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-spill-local`. + * @module @deepseek-ai/dsh-spill-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-local' /** Cordis companion plugin name. */ export const name = 'spill-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'LocalSpillStore', - effects: [ - 'ctx.provide("spillStore")', - ], - services: [ - 'spillStore', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts index 4af3a81f70..82a4bee211 100644 --- a/packages/spill/spill-policy/src/invariant.ts +++ b/packages/spill/spill-policy/src/invariant.ts @@ -1,31 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill-policy`. @module @deepseek-ai/dsh-spill-policy/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-spill-policy`. + * @module @deepseek-ai/dsh-spill-policy/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-policy' /** Cordis companion plugin name. */ export const name = 'spill-policy-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'spill-policy', - inject: [ - 'tools', - ], - validate: (fiber, effectLabels) => { - const installed = effectLabels.has('ctx.on("tools/post-execute")') - const enabled = (fiber.config as { maxInlineBytes?: number }).maxInlineBytes !== undefined - return installed === enabled - ? undefined - : 'the post-execute spill policy listener must exist exactly when maxInlineBytes is configured' - }, - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -34,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts index ba971b8bc2..5011ac1d52 100644 --- a/packages/spill/spill/src/invariant.ts +++ b/packages/spill/spill/src/invariant.ts @@ -1,21 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-spill`. @module @deepseek-ai/dsh-spill/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-spill`. + * @module @deepseek-ai/dsh-spill/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill' /** Cordis companion plugin name. */ export const name = 'spill-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ -const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'spillStore', value => serviceShapeViolation(value, { - methods: ['saveText'], - })) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -24,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts index 0c17843ff7..85c1601348 100644 --- a/packages/subagent/subagent-acp/src/invariant.ts +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-acp`. @module @deepseek-ai/dsh-subagent-acp/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-acp`. + * @module @deepseek-ai/dsh-subagent-acp/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-acp' /** Cordis companion plugin name. */ export const name = 'subagent-acp-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'subagent-acp', - inject: [ - 'subagents', - ], - effects: [ - 'subagents.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts index 2fc8a0f696..e3d65701b1 100644 --- a/packages/subagent/subagent-fork/src/invariant.ts +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-fork`. @module @deepseek-ai/dsh-subagent-fork/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-fork`. + * @module @deepseek-ai/dsh-subagent-fork/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork' /** Cordis companion plugin name. */ export const name = 'subagent-fork-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'subagent-fork', - inject: [ - 'subagents', - ], - effects: [ - 'subagents.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index caaf598f55..7b8bfc36e2 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -1,24 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-subagent-inprocess. @module @deepseek-ai/dsh-subagent-inprocess/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-inprocess`. + * @module @deepseek-ai/dsh-subagent-inprocess/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' /** Cordis companion plugin name. */ export const name = 'subagent-inprocess-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert that structured-output guidance names the tool it actually installs. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') - assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), - 'the structured-output tool must retain a stable lowercase protocol name') - assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), - 'the structured-output instruction must name the exact installed tool') -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts index 28201593e4..0ba0182f9f 100644 --- a/packages/subagent/subagent-spawn/src/invariant.ts +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent-spawn`. @module @deepseek-ai/dsh-subagent-spawn/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-spawn`. + * @module @deepseek-ai/dsh-subagent-spawn/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-spawn' /** Cordis companion plugin name. */ export const name = 'subagent-spawn-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'subagent-spawn', - inject: [ - 'subagents', - ], - effects: [ - 'subagents.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index 9eed9ae9e3..c273ce5209 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -1,37 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-subagent-subprocess. @module @deepseek-ai/dsh-subagent-subprocess/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-subprocess`. + * @module @deepseek-ai/dsh-subagent-subprocess/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** Cordis companion plugin name. */ export const name = 'subagent-subprocess-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert ambient credential scrubbing and explicit credential precedence. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { buildChildEnv } = await import('./index.ts') - const ambientProbe = `DSH_INVARIANT_AMBIENT_TOKEN_${process.pid}` - assertInvariant(fail, SENSITIVE_ENV_PATTERN.test(ambientProbe), - 'the invariant ambient probe must remain credential-shaped') - process.env[ambientProbe] = 'must-not-reach-child' - let scrubbed: NodeJS.ProcessEnv - try { - scrubbed = buildChildEnv({}) - } finally { - Reflect.deleteProperty(process.env, ambientProbe) - } - assertInvariant(fail, !Object.hasOwn(scrubbed, ambientProbe), - 'subprocess environments must omit every credential-shaped ambient variable') - - const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) - assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', - 'explicit child credentials must be applied after ambient scrubbing') -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index 3217a3dd9a..3c350c13a1 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -1,30 +1,89 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-subagent`. @module @deepseek-ai/dsh-subagent/invariant */ +/** Package-owned subagent registry and lifecycle invariants. @module @deepseek-ai/dsh-subagent/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { SubagentProvider } from './types.ts' +import type { SubagentRunEndInfo, SubagentRunInfo } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent' /** Cordis companion plugin name. */ export const name = 'subagent-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'SubagentService', - effects: [ - 'ctx.provide("subagents")', - ], - services: [ - 'subagents', - ], - }) +/** Assert that a terminal lifecycle payload matches its start identity. */ +function validateRunEnd(start: SubagentRunInfo, end: SubagentRunEndInfo, fail: InvariantFailure): void { + if (start.provider !== end.provider || start.id !== end.id || start.local !== end.local) { + fail(`subagent/end identity diverges from subagent/start for run ${JSON.stringify(end.runId)}`) + } } +/** Install provider-registry and start/end pairing checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const providers = new Set(ctx.subagents.list()) + const runs = new Map() + const stagedProviders = new WeakSet() + const stagedRemovals = new Set() + const stagedStarts = new WeakSet() + const stagedEnds = new WeakSet() + + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName === 'subagent/provider-added') { + const provider = args[0] as SubagentProvider + if (provider.name.length === 0) fail('subagent provider names must be non-empty') + if (providers.has(provider.name)) fail(`subagent/provider-added repeated ${JSON.stringify(provider.name)}`) + stagedProviders.add(provider) + return + } + if (eventName === 'subagent/provider-removed') { + const providerName = args[0] as string + if (!providers.has(providerName)) fail(`subagent/provider-removed names unknown provider ${JSON.stringify(providerName)}`) + stagedRemovals.add(providerName) + return + } + if (eventName === 'subagent/start') { + const info = args[0] as SubagentRunInfo + if (!providers.has(info.provider)) fail(`subagent/start names inactive provider ${JSON.stringify(info.provider)}`) + if (String(info.runId).length === 0 || String(info.id).length === 0) { + fail('subagent/start runId and child id must be non-empty') + } + if (runs.has(info.runId)) fail(`subagent/start repeated run id ${JSON.stringify(info.runId)}`) + stagedStarts.add(info) + return + } + if (eventName !== 'subagent/end') return + const info = args[0] as SubagentRunEndInfo + const start = runs.get(info.runId) + if (start === undefined) fail(`subagent/end has no matching subagent/start for run ${JSON.stringify(info.runId)}`) + validateRunEnd(start, info, fail) + stagedEnds.add(info) + }, { global: true }) + + ctx.on('subagent/provider-added', (provider) => { + /* v8 ignore next -- internal/dispatch stages the same provider object */ + if (!stagedProviders.delete(provider)) return + providers.add(provider.name) + }, { global: true }) + ctx.on('subagent/provider-removed', (providerName) => { + /* v8 ignore next -- internal/dispatch stages the same provider name */ + if (!stagedRemovals.delete(providerName)) return + providers.delete(providerName) + }, { global: true }) + ctx.on('subagent/start', (info) => { + /* v8 ignore next -- internal/dispatch stages the same lifecycle object */ + if (!stagedStarts.delete(info)) return + runs.set(info.runId, info) + }, { global: true }) + ctx.on('subagent/end', (info) => { + /* v8 ignore next -- internal/dispatch stages the same lifecycle object */ + if (!stagedEnds.delete(info)) return + runs.delete(info.runId) + }, { global: true }) +}, { inject: ['subagents'] }) + /** - * Register this package's invariant companion. + * Register the subagent invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts new file mode 100644 index 0000000000..ac3a919862 --- /dev/null +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' +import type { + SubagentProvider, + SubagentRunEndInfo, + SubagentRunInfo, +} from '@deepseek-ai/dsh-subagent' +import * as SubagentInvariant from '@deepseek-ai/dsh-subagent/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(InvariantService) + await ctx.plugin(SubagentInvariant) + return ctx +} + +const provider = (name: string): SubagentProvider => ({ + name, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => { throw new Error('not used') }, +}) + +const start = (overrides: Partial = {}): SubagentRunInfo => ({ + runId: SubagentRunId('run-1'), + provider: 'mock', + id: SessionId('child-1'), + local: false, + ...overrides, +}) + +const end = (overrides: Partial = {}): SubagentRunEndInfo => ({ + ...start(), + stopReason: 'completed', + ...overrides, +}) + +function emitRun(ctx: Context, name: 'subagent/start', info: SubagentRunInfo): void +function emitRun(ctx: Context, name: 'subagent/end', info: SubagentRunEndInfo): void +function emitRun(ctx: Context, name: 'subagent/start' | 'subagent/end', info: SubagentRunInfo | SubagentRunEndInfo): void { + ctx.emit(scopeTarget(ctx.subagents, {}), name as 'subagent/start', info) +} + +describe('subagent invariants', () => { + it('accepts provider and run lifecycle pairs', async () => { + const ctx = await setup() + const mock = provider('mock') + ctx.emit('subagent/provider-added', mock) + emitRun(ctx, 'subagent/start', start()) + emitRun(ctx, 'subagent/end', end()) + ctx.emit('subagent/provider-removed', 'mock') + ctx.emit('tools/change') + }) + + it('rejects malformed provider transitions', async () => { + const ctx = await setup() + expect(() => { ctx.emit('subagent/provider-added', provider('')) }).toThrow(/names must be non-empty/) + const mock = provider('mock') + ctx.emit('subagent/provider-added', mock) + expect(() => { ctx.emit('subagent/provider-added', mock) }).toThrow(/repeated "mock"/) + expect(() => { ctx.emit('subagent/provider-removed', 'missing') }).toThrow(/unknown provider/) + }) + + it('rejects malformed and unpaired run transitions', async () => { + const ctx = await setup() + expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/inactive provider/) + ctx.emit('subagent/provider-added', provider('mock')) + expect(() => { emitRun(ctx, 'subagent/start', start({ runId: SubagentRunId('') })) }) + .toThrow(/runId and child id must be non-empty/) + emitRun(ctx, 'subagent/start', start()) + expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/repeated run id/) + expect(() => { emitRun(ctx, 'subagent/end', end({ runId: SubagentRunId('missing') })) }) + .toThrow(/no matching subagent\/start/) + expect(() => { emitRun(ctx, 'subagent/end', end({ id: SessionId('other') })) }) + .toThrow(/identity diverges/) + }) +}) diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index b1bfa209a1..bd30f4c563 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-subagent`. @module @deepseek-ai/dsh-tool-subagent/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent`. + * @module @deepseek-ai/dsh-tool-subagent/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' /** Cordis companion plugin name. */ export const name = 'tool-subagent-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-subagent', - inject: [ - 'tools', - 'subagents', - ], - effects: [ - 'ctx.on("subagent/provider-added")', - 'ctx.on("subagent/provider-removed")', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index ac29b83cff..e94876100e 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -1,34 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-acp-snapshot. @module @deepseek-ai/dsh-acp-snapshot/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-acp-snapshot`. + * @module @deepseek-ai/dsh-acp-snapshot/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-snapshot' /** Cordis companion plugin name. */ export const name = 'acp-snapshot-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert stable JSON-RPC correlation and volatile-value tokenization. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { normalizeStdout } = await import('./normalize.ts') - const sessionId = '12345678-1234-1234-1234-123456789abc' - const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } - const raw = [ - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), - ].join('\n') - const normalized = normalizeStdout(raw, volatile) - assertInvariant(fail, - normalized.includes('"id":1') - && normalized.includes('"cwd":"{{cwd}}"') - && normalized.includes('"sessionId":"{{sessionId}}"'), - 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') - assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, - 'ACP stdout normalization must be idempotent') -} +/** + * No runtime invariant: this test-support package owns no production event stream or mutable data; + * consuming test suites exercise its behavior. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index 69792b77ea..33ee4474f9 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -1,25 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-agent-loop-testkit. @module @deepseek-ai/dsh-agent-loop-testkit/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-loop-testkit`. + * @module @deepseek-ai/dsh-agent-loop-testkit/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop-testkit' /** Cordis companion plugin name. */ export const name = 'agent-loop-testkit-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the awaitable helper shape and optional-options call boundary. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { mountAgentLoopTestDependencies } = await import('./index.ts') - assertInvariant(fail, - mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', - 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') - assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, - 'the prerequisite mount helper must keep its options argument optional') -} +/** + * No runtime invariant: this test-support package owns no production event stream or mutable data; + * consuming test suites exercise its behavior. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 7b922bcda9..1d5a377936 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -12,38 +12,37 @@ interface Config { } ``` -Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. A package is selected only when the service is enabled, the empty allowlist or at least one allowlist pattern matches its full npm name, and no blocklist pattern matches. Blocklist matches therefore override allowlist matches. +Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: []`. A package is selected only when the service is enabled, the allowlist is empty or at least one allowlist pattern matches its full npm name, and no blocklist pattern matches. Blocklist matches therefore override allowlist matches. Each entry is a case-sensitive JavaScript regular-expression source compiled with `new RegExp(pattern)`. Matching is unanchored unless the source supplies `^` and `$`; `/pattern/flags` syntax is not parsed. Blank, whitespace-padded, invalid, or duplicate entries within one list fail service startup. A valid pattern may match no currently loaded package so later loading and HMR remain deterministic. `ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Synchronous or asynchronous installer completion is joined before registration succeeds; failure disposes the child and releases ownership atomically. -The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes the listeners and reservation completely. A companion can therefore reload and register the same package name without retaining trace state or duplicate listeners; packages that need an existing baseline rebuild it during installation. +The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes listeners, trace state, and the reservation. A companion can therefore reload and register the same package name without retaining its previous state. Session-backed companions rebuild their baseline from durable events; live-only companions observe operations that begin after reload. -`InvariantError` extends `Error`, carries stable `code: 'INVARIANT'`, and exposes the owning `packageName` without adding a product-package dependency to the service. +`InvariantError` extends `Error`, carries stable `code: 'INVARIANT'`, and exposes the owning `packageName` without adding a product dependency to the service. ## Package companions -Every companion installs at least one executable, package-specific contract and reports failure through its bound reporter. There is no generated or ownership-only baseline. `pnpm run verify-package-invariants` rejects generated markers, empty installers, installers that ignore the reporter, duplicate name-based plugin observers, incorrect registration names, and incomplete export, publication, dependency, TypeScript-reference, or bundle wiring. +Publication and registration are exhaustive; runtime assertions are deliberately not synthetic. A companion installs a check only when its package owns an observable event relationship or relevant mutable-data relationship. Confirming a required method, plugin name, injection, effect, or fixed pure-function result is a type, load, or unit-test concern rather than a runtime invariant. -Packages select the narrowest runtime form that protects their public contract: +When no plausible runtime relationship exists, the companion uses an empty installer with a package-specific leading `No runtime invariant:` comment explaining why. This is common for pure utilities, thin implementations whose behavior is already observed through their seam, composition-only packages, binaries, persistence adapters whose contracts require crash/round-trip tests, and test-support packages. The explanation must be revisited when the owner gains mutable state or an event protocol. -| Package shape | Companion check | +The current executable companions protect these relationships: + +| Companion | Checks | |---|---| -| Cordis plugin | `observePluginInvariant` validates the plugin's own declared name, required injections, owned effect group, provided services, and optional package-specific relation for existing, late, and HMR-activated fibers. | -| Cordis service seam | `observeServiceInvariant` plus `serviceShapeViolation` validates current and future structural implementations, including conforming third-party backends and test doubles. | -| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape during child startup. | +| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. | +| `dsh-llm`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | +| `dsh-compact`, `dsh-hook-protocol`, `dsh-bash` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. | +| `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | +| `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. | +| `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. | +| `dsh-time-context` | Durable clock readings agree with their turn, step, elapsed baseline, and event timestamp. | -Four companions additionally install stateful event and request checks: +The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no product checks, and loading a companion without the service waits on its declared `invariants` injection. -| Companion | Registration | Checks | -|---|---|---| -| `@deepseek-ai/dsh-session/invariant` | `@deepseek-ai/dsh-session` | sequence, turn/step enclosure, and same-step tool call/result trace | -| `@deepseek-ai/dsh-agent/invariant` | `@deepseek-ai/dsh-agent` | agent-status transitions | -| `@deepseek-ai/dsh-scope/invariant` | `@deepseek-ai/dsh-scope` | scoped-event carrier presence and subject consistency | -| `@deepseek-ai/dsh-agent-loop/invariant` | `@deepseek-ai/dsh-agent-loop` | loop-built model-request reconstruction from the session log | - -The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no checks; loading a companion without the service remains pending on its declared `invariants` dependency. Name-based plugin observers match only a fiber's own declared runtime name, not anonymous child fibers that inherit a parent display name. They avoid importing the product entrypoint before it is loaded; pure-library checks likewise defer owner imports into the installer child so Vitest mocks and deployment loaders establish their module boundary first. +`pnpm run verify-package-invariants` discovers all workspace packages. It rejects generated markers, unexplained empty installers, non-empty installers that omit or ignore the reporter, incorrect registration names, and incomplete export, publication, dependency, TypeScript-reference, or bundle wiring. This source rule is a minimum ownership check; focused tests prove each executable companion's semantics. ## Composition @@ -62,11 +61,13 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Plugin and service helpers multiplex package contracts through indexed lifecycle listeners shared by the Cordis root, while contribution disposal removes only that owner's contract. Vitest gives every ordinary root an explicitly enabled service and mounts the current test package's companion; one exhaustive topology test mounts all companions once, and focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. +The standard agent spine mounts the service and its four core stateful companions. Custom compositions explicitly add companions for other loaded packages whose contracts they want checked; filters can disable or select registrations without changing package entrypoints. + +Every ordinary Vitest topology mounts an explicitly enabled service and the current test package's companion. Focused suites cover valid and invalid observations for executable companions, while one exhaustive topology mounts all companions to prove registration and disposal wiring. ## Model Experience -None, as the service and companions observe runtime events and requests but never alter prompts, messages, schemas, streams, or tool results. +None. The service and companions observe runtime events, mutable snapshots, and requests but never alter prompts, messages, schemas, streams, or tool results. #### KV Cache effect @@ -74,7 +75,6 @@ None; invariant checks do not assemble or send provider requests. ## Known Limitations and Deferred Work -- A name-based plugin observer assumes Cordis plugin names are unique within one root; a package can provide the exact callback when importing it does not preload an unrelated runtime. -- Pure-library contracts are sampled when their companion child activates rather than observed continuously; mutable package behavior belongs on an event, service, or plugin-fiber observer. -- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot calls remain outside that companion's marker contract. +- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot LLM calls remain outside that marker contract. +- Live-only lifecycle companions cannot reconstruct operations that began before their own reload. Standard and test compositions mount them before the corresponding operations begin. - Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 265596b677..43216baa1e 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-invariants */ -import { Context, FiberState, Service } from 'cordis' -import type { Fiber, Inject, Plugin } from 'cordis' +import { Context, Service } from 'cordis' +import type { Inject } from 'cordis' import z from 'schemastery' import type Schema from 'schemastery' @@ -41,300 +41,6 @@ export interface InvariantInstaller { readonly inject?: Inject } -/** Runtime facts one package expects from its Cordis plugin fiber. */ -export interface PluginInvariantContract { - /** Exact plugin value when checking it does not preload an unrelated runtime; otherwise matching uses `name`. */ - readonly plugin?: Plugin - /** Exact Cordis display name for the plugin fiber. */ - readonly name: string - /** Required service injections that must be present when the fiber activates. */ - readonly inject?: readonly string[] - /** Required owned effect labels; an inner array means at least one alternative must exist. */ - readonly effects?: readonly (string | readonly string[])[] - /** Services the active fiber must provide. */ - readonly services?: readonly string[] - /** Optional package-owned validation after the structural checks pass. */ - readonly validate?: (fiber: Fiber, effectLabels: ReadonlySet) => string | undefined -} - -/** Collect all live effect labels below a plugin fiber. */ -function collectEffectLabels(fiber: Fiber): ReadonlySet { - const labels = new Set() - const visit = (effects: ReturnType): void => { - for (const effect of effects) { - labels.add(effect.label) - visit(effect.children) - } - } - visit(fiber.getEffects()) - return labels -} - -/** One package check routed by a root-shared plugin lifecycle dispatcher. */ -interface PluginObservation { - readonly callback: globalThis.Function | undefined - readonly contract: PluginInvariantContract - readonly fail: InvariantFailure -} - -/** Indexed plugin checks and the two lifecycle listeners shared by one root. */ -interface PluginObservationHub { - readonly byCallback: Map> - readonly byName: Map> -} - -const pluginObservationHubs = new WeakMap() - -/** Check one already-matched active plugin fiber. */ -function inspectPluginObservation(observation: PluginObservation, fiber: Fiber): void { - if (fiber.state !== FiberState.ACTIVE || fiber.uid === null) return - const { callback, contract, fail } = observation - if (callback !== undefined && fiber.name !== contract.name) { - fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) - } - const injections = new Set(Object.keys(fiber.inject)) - for (const service of contract.inject ?? []) { - if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) - } - - const effectLabels = collectEffectLabels(fiber) - for (const requirement of contract.effects ?? []) { - const alternatives = typeof requirement === 'string' ? [requirement] : requirement - if (!alternatives.some(label => effectLabels.has(label))) { - fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) - } - } - for (const service of contract.services ?? []) { - const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { - const implementation = fiber.ctx.reflect.store[key as symbol] - return implementation?.fiber === fiber && implementation.name === service - }) - if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) - } - const message = contract.validate?.(fiber, effectLabels) - if (message !== undefined) fail(message) -} - -/** Route one lifecycle notification only to checks that can match its runtime. */ -function inspectObservedPlugin(hub: PluginObservationHub, fiber: Fiber): void { - const callback = fiber.runtime?.callback - if (callback !== undefined) { - for (const observation of hub.byCallback.get(callback) ?? []) { - inspectPluginObservation(observation, fiber) - } - } - const runtimeName = fiber.runtime?.name - if (runtimeName !== undefined) { - for (const observation of hub.byName.get(runtimeName) ?? []) { - inspectPluginObservation(observation, fiber) - } - } -} - -/** Return the root's shared plugin dispatcher, creating its two listeners once. */ -function pluginObservationHub(ctx: Context): PluginObservationHub { - const root = ctx.root - const existing = pluginObservationHubs.get(root) - if (existing !== undefined) return existing - - const hub: PluginObservationHub = { - byCallback: new Map(), - byName: new Map(), - } - pluginObservationHubs.set(root, hub) - root.on('internal/plugin', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) - root.on('internal/status', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) - return hub -} - -/** Add one plugin observation to a typed exact-key index. */ -function addIndexedPluginObservation( - index: Map>, - key: Key, - observation: PluginObservation, -): () => void { - const observations = index.get(key) ?? new Set() - index.set(key, observations) - observations.add(observation) - return () => { - observations.delete(observation) - if (observations.size === 0) index.delete(key) - } -} - -/** Add one observation to its exact callback or runtime-name index. */ -function addPluginObservation(hub: PluginObservationHub, observation: PluginObservation): () => void { - if (observation.callback === undefined) { - return addIndexedPluginObservation(hub.byName, observation.contract.name, observation) - } - return addIndexedPluginObservation(hub.byCallback, observation.callback, observation) -} - -/** - * Observe one package plugin and fail whenever an active fiber violates its - * declared name, dependency, effect, service, or package-specific contract. - * Existing fibers are checked immediately; later starts and HMR activations - * are checked through two indexed lifecycle listeners shared by the root. - * @param ctx - invariant child context that owns the observers. - * @param fail - reporter bound to the package that owns the plugin. - * @param contract - expected runtime facts for the package plugin. - * @returns nothing after lifecycle observers are installed. - */ -export function observePluginInvariant( - ctx: Context, - fail: InvariantFailure, - contract: PluginInvariantContract, -): void { - const callback = contract.plugin === undefined ? undefined : ctx.registry.resolve(contract.plugin) - if (contract.plugin !== undefined && callback === undefined) { - fail('invariant contract does not identify a Cordis plugin') - } - - const observation: PluginObservation = { callback, contract, fail } - - if (contract.plugin === undefined) { - for (const runtime of ctx.registry.values()) { - if (runtime.name !== contract.name) continue - for (const fiber of runtime.fibers) inspectPluginObservation(observation, fiber) - } - } else { - for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) { - inspectPluginObservation(observation, fiber) - } - } - const hub = pluginObservationHub(ctx) - ctx.effect( - () => addPluginObservation(hub, observation), - `invariants.observePlugin(${JSON.stringify(contract.name)})`, - ) -} - -/** One structural check routed by a root-shared service lifecycle dispatcher. */ -interface ServiceObservation { - readonly fail: InvariantFailure - readonly validate: (value: unknown) => string | undefined -} - -/** Service checks and the single service listener shared by one root. */ -interface ServiceObservationHub { - readonly byName: Map> -} - -const serviceObservationHubs = new WeakMap() - -/** Check one present service implementation. */ -function inspectServiceObservation(observation: ServiceObservation, value: unknown): void { - if (value === undefined) return - const message = observation.validate(value) - if (message !== undefined) observation.fail(message) -} - -/** Return the root's shared service dispatcher, creating its listener once. */ -function serviceObservationHub(ctx: Context): ServiceObservationHub { - const root = ctx.root - const existing = serviceObservationHubs.get(root) - if (existing !== undefined) return existing - - const hub: ServiceObservationHub = { byName: new Map() } - serviceObservationHubs.set(root, hub) - root.on('internal/service', (name, value: unknown) => { - for (const observation of hub.byName.get(name) ?? []) { - inspectServiceObservation(observation, value) - } - }, { global: true }) - return hub -} - -/** Add one service observation to its exact service-name index. */ -function addServiceObservation( - hub: ServiceObservationHub, - serviceName: string, - observation: ServiceObservation, -): () => void { - const observations = hub.byName.get(serviceName) ?? new Set() - hub.byName.set(serviceName, observations) - observations.add(observation) - return () => { - observations.delete(observation) - if (observations.size === 0) hub.byName.delete(serviceName) - } -} - -/** - * Validate every current and future implementation bound to one Cordis - * service through the root's indexed shared service listener. - * @param ctx - invariant child context that owns the service observer. - * @param fail - reporter bound to the package that owns the service seam. - * @param serviceName - Cordis service name to observe. - * @param validate - returns the violated contract, or `undefined` for a valid implementation. - * @returns nothing after the current binding is checked and the observer is installed. - */ -export function observeServiceInvariant( - ctx: Context, - fail: InvariantFailure, - serviceName: string, - validate: (value: unknown) => string | undefined, -): void { - const observation: ServiceObservation = { fail, validate } - const current: unknown = ctx.get(serviceName) - inspectServiceObservation(observation, current) - const hub = serviceObservationHub(ctx) - ctx.effect( - () => addServiceObservation(hub, serviceName, observation), - `invariants.observeService(${JSON.stringify(serviceName)})`, - ) -} - -/** Structural runtime surface required from a Cordis service implementation. */ -export interface ServiceShapeInvariant { - /** Members that must be callable. */ - readonly methods: readonly string[] - /** Members that must be non-empty strings. */ - readonly stringProperties?: readonly string[] -} - -/** - * Describe the first missing member in a structural service implementation. - * This deliberately accepts test doubles and third-party implementations that - * satisfy the seam without inheriting the first-party abstract service class. - * @param value - candidate service implementation. - * @param shape - callable and string members owned by the service package. - * @returns the violated shape, or `undefined` when the candidate conforms. - */ -export function serviceShapeViolation( - value: unknown, - shape: ServiceShapeInvariant, -): string | undefined { - if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { - return 'service implementation must be an object' - } - const record = value as Record - for (const method of shape.methods) { - if (typeof record[method] !== 'function') return `service implementation must expose method ${JSON.stringify(method)}` - } - for (const property of shape.stringProperties ?? []) { - if (typeof record[property] !== 'string' || record[property].length === 0) { - return `service implementation must expose non-empty string ${JSON.stringify(property)}` - } - } - return undefined -} - -/** - * Report a failed package-owned synchronous invariant. - * @param fail - reporter bound to the package that owns the assertion. - * @param condition - condition that must hold. - * @param message - violated contract when `condition` is false. - * @returns nothing when the condition holds. - */ -export function assertInvariant( - fail: InvariantFailure, - condition: unknown, - message: string, -): void { - if (!condition) fail(message) -} - /** Internal effect shape used to join child startup before a companion loads. */ interface PendingInvariantRegistration extends PromiseLike<() => void> { (): void | Promise diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts index bd9efcb74c..7780e987f5 100644 --- a/packages/support/invariants/src/invariant.ts +++ b/packages/support/invariants/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-invariants`. @module @deepseek-ai/dsh-invariants/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-invariants`. + * @module @deepseek-ai/dsh-invariants/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import InvariantService, { observePluginInvariant, type InvariantInstaller } from './index.ts' +import type { InvariantInstaller } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' /** Cordis companion plugin name. */ export const name = 'invariants-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - plugin: InvariantService, - name: 'InvariantService', - effects: [ - 'ctx.provide("invariants")', - ], - services: [ - 'invariants', - ], - }) -} +/** + * No runtime invariant: registration ownership and child lifecycle are the service's mutation + * boundary itself; observing them from the same registry would only duplicate its implementation. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index 76fda543ad..b190379302 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -2,19 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' import InvariantService, { InvariantError, - assertInvariant, - observePluginInvariant, - observeServiceInvariant, - serviceShapeViolation, type Config, - type InvariantInstaller, - type PluginInvariantContract, } from '@deepseek-ai/dsh-invariants' declare module 'cordis' { interface Context { invariantProbe: InvariantProbeService - watchedInvariantProbe: WatchedInvariantProbeService } interface Events { @@ -28,12 +21,6 @@ class InvariantProbeService extends Service { } } -class WatchedInvariantProbeService extends Service { - constructor(ctx: Context) { - super(ctx, 'watchedInvariantProbe') - } -} - interface RuntimeRegistration extends PromiseLike<() => void> { (): void | Promise } @@ -299,277 +286,3 @@ describe('InvariantService lifecycle', () => { expect(() => service.register('@deepseek-ai/dsh-session', () => {})).toThrow(/inactive/i) }) }) - -describe('package-owned invariant helpers', () => { - interface InvariantDisposer { - (): void | Promise - } - - async function registerInstaller( - ctx: Context, - packageName: string, - installer: InvariantInstaller, - ): Promise { - const registration = runtimeRegistration(ctx.invariants.register(packageName, installer)) - const dispose = await Promise.resolve(registration) - return dispose - } - - function effectPlugin(options: { - name?: string - inject?: string[] - effect?: string - service?: string - } = {}) { - return { - name: options.name ?? 'effect-probe', - inject: options.inject ?? [], - apply(ctx: Context) { - if (options.service !== undefined) ctx.provide(options.service, {}) - if (options.effect !== undefined) { - ctx.effect(() => { - ctx.effect(() => () => {}, `${options.effect}.child`) - return () => {} - }, options.effect) - } - }, - } - } - - async function expectPluginViolation( - contract: PluginInvariantContract, - plugin: ReturnType, - message: RegExp, - ): Promise { - const { ctx } = await setup() - await registerInstaller(ctx, `@deepseek-ai/${contract.name}`, (child, fail) => { - observePluginInvariant(child, fail, contract) - }) - await expect(Promise.resolve(ctx.plugin(plugin))).rejects.toThrow(message) - } - - it('checks existing and later plugin fibers, including nested effects and alternatives', async () => { - const { ctx } = await setup() - await ctx.plugin(InvariantProbeService) - const plugin = effectPlugin({ - inject: ['invariantProbe'], - effect: 'probe.effect', - service: 'pluginProbe', - }) - await ctx.plugin(plugin) - const validated = vi.fn(() => undefined) - await registerInstaller(ctx, '@deepseek-ai/dsh-existing-probe', (child, fail) => { - observePluginInvariant(child, fail, { - plugin, - name: 'effect-probe', - inject: ['invariantProbe'], - effects: [['missing.effect', 'probe.effect.child']], - services: ['pluginProbe'], - validate: validated, - }) - }) - expect(validated).toHaveBeenCalledOnce() - - const later = effectPlugin({ name: 'later-probe', effect: 'later.effect' }) - await registerInstaller(ctx, '@deepseek-ai/dsh-later-probe', (child, fail) => { - observePluginInvariant(child, fail, { - plugin: later, - name: 'later-probe', - effects: ['later.effect'], - }) - }) - await ctx.plugin(later) - }) - - it('matches package plugins by Cordis name without importing their callback', async () => { - const { ctx } = await setup() - const plugin = { - name: 'name-only-probe', - apply(pluginCtx: Context) { - pluginCtx.effect(() => () => {}, 'name-only.effect') - pluginCtx.inject([], () => {}) - }, - } - await registerInstaller(ctx, '@deepseek-ai/dsh-name-only-probe', (child, fail) => { - observePluginInvariant(child, fail, { - name: 'name-only-probe', - effects: ['name-only.effect'], - }) - }) - await ctx.plugin(plugin) - }) - - it('multiplexes same-runtime plugin checks through one root listener pair and disposes each owner', async () => { - const { ctx } = await setup() - const firstValidation = vi.fn(() => undefined) - const secondValidation = vi.fn(() => undefined) - const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-first', (child, fail) => { - observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: firstValidation }) - }) - const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-second', (child, fail) => { - observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: secondValidation }) - }) - const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) - expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/plugin")')).toHaveLength(1) - expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/status")')).toHaveLength(1) - - const plugin = effectPlugin({ name: 'shared-plugin-probe' }) - const firstFiber = await ctx.plugin(plugin) - expect(firstValidation).toHaveBeenCalledOnce() - expect(secondValidation).toHaveBeenCalledOnce() - - await first() - await firstFiber.dispose() - const secondFiber = await ctx.plugin(plugin) - expect(firstValidation).toHaveBeenCalledOnce() - expect(secondValidation).toHaveBeenCalledTimes(2) - - await second() - await secondFiber.dispose() - await ctx.plugin(plugin) - expect(firstValidation).toHaveBeenCalledOnce() - expect(secondValidation).toHaveBeenCalledTimes(2) - }) - - it('rejects a contract that does not identify a plugin', async () => { - const { ctx } = await setup() - const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-plugin', (child, fail) => { - observePluginInvariant(child, fail, { - plugin: {} as never, - name: 'invalid-plugin', - }) - })) - await expect(Promise.resolve(registration)).rejects.toThrow(/does not identify a Cordis plugin/) - }) - - it('rejects wrong plugin names, missing injections, effects, services, and custom checks', async () => { - const wrongName = effectPlugin({ name: 'actual-name', effect: 'probe.effect' }) - await expectPluginViolation({ - plugin: wrongName, - name: 'expected-name', - }, wrongName, /plugin name must be "expected-name"/) - - const missingInjection = effectPlugin({ effect: 'probe.effect' }) - await expectPluginViolation({ - plugin: missingInjection, - name: 'effect-probe', - inject: ['missingService'], - }, missingInjection, /must inject "missingService"/) - - const missingEffect = effectPlugin() - await expectPluginViolation({ - plugin: missingEffect, - name: 'effect-probe', - effects: [['first.effect', 'second.effect']], - }, missingEffect, /must own effect "first.effect" or "second.effect"/) - - const missingService = effectPlugin({ effect: 'probe.effect' }) - await expectPluginViolation({ - plugin: missingService, - name: 'effect-probe', - services: ['missingService'], - }, missingService, /must provide service "missingService"/) - - const invalidCustom = effectPlugin({ effect: 'probe.effect' }) - await expectPluginViolation({ - plugin: invalidCustom, - name: 'effect-probe', - validate: () => 'custom plugin contract failed', - }, invalidCustom, /custom plugin contract failed/) - }) - - it('checks existing and future service implementations while ignoring unrelated changes', async () => { - const existing = await setup() - await existing.ctx.plugin(WatchedInvariantProbeService) - await registerInstaller(existing.ctx, '@deepseek-ai/dsh-existing-service', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', value => ( - value instanceof WatchedInvariantProbeService ? undefined : 'wrong watched service' - )) - }) - - const future = await setup() - await registerInstaller(future.ctx, '@deepseek-ai/dsh-future-service', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', value => ( - value instanceof WatchedInvariantProbeService ? undefined : 'wrong watched service' - )) - }) - await future.ctx.plugin(InvariantProbeService) - await future.ctx.plugin(WatchedInvariantProbeService) - - const invalid = await setup() - await registerInstaller(invalid.ctx, '@deepseek-ai/dsh-invalid-service', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', () => 'wrong watched service') - }) - await expect(Promise.resolve(invalid.ctx.plugin(WatchedInvariantProbeService))) - .rejects.toThrow(/wrong watched service/) - }) - - it('multiplexes same-name service checks through one root listener and disposes each owner', async () => { - const { ctx } = await setup() - const firstValidation = vi.fn(() => undefined) - const secondValidation = vi.fn(() => undefined) - const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-first', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', firstValidation) - }) - const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-second', (child, fail) => { - observeServiceInvariant(child, fail, 'watchedInvariantProbe', secondValidation) - }) - const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) - expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/service")')).toHaveLength(1) - - const firstFiber = await ctx.plugin(WatchedInvariantProbeService) - expect(firstValidation).toHaveBeenCalledOnce() - expect(secondValidation).toHaveBeenCalledOnce() - - await first() - const firstCallsAfterDisposal = firstValidation.mock.calls.length - const secondCallsBeforeRemount = secondValidation.mock.calls.length - await firstFiber.dispose() - const secondFiber = await ctx.plugin(WatchedInvariantProbeService) - expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterDisposal) - expect(secondValidation.mock.calls.length).toBeGreaterThan(secondCallsBeforeRemount) - - await second() - const firstCallsAfterBothDisposals = firstValidation.mock.calls.length - const secondCallsAfterBothDisposals = secondValidation.mock.calls.length - await secondFiber.dispose() - await ctx.plugin(WatchedInvariantProbeService) - expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterBothDisposals) - expect(secondValidation).toHaveBeenCalledTimes(secondCallsAfterBothDisposals) - }) - - it('reports synchronous package assertions through the bound failure reporter', async () => { - const { ctx } = await setup() - const valid = await registerInstaller(ctx, '@deepseek-ai/dsh-valid-assertion', (_child, fail) => { - assertInvariant(fail, true, 'must stay true') - }) - await valid() - - const invalid = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-assertion', (_child, fail) => { - assertInvariant(fail, false, 'must stay true') - })) - await expect(Promise.resolve(invalid)).rejects.toThrow(/must stay true/) - }) - - it('accepts structural service implementations and test doubles', () => { - expect(serviceShapeViolation({ kind: 'probe', run() {} }, { - methods: ['run'], - stringProperties: ['kind'], - })).toBeUndefined() - expect(serviceShapeViolation(Object.assign(() => {}, { run() {} }), { - methods: ['run'], - })).toBeUndefined() - }) - - it.each([ - { value: null, message: 'service implementation must be an object' }, - { value: 42, message: 'service implementation must be an object' }, - { value: {}, message: 'service implementation must expose method "run"' }, - { value: { run() {}, kind: '' }, message: 'service implementation must expose non-empty string "kind"' }, - ])('rejects invalid structural service implementations: $message', ({ value, message }) => { - expect(serviceShapeViolation(value, { - methods: ['run'], - stringProperties: ['kind'], - })).toBe(message) - }) -}) diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts index 9c82c33686..36a3f8eeca 100644 --- a/packages/support/llm-replay/src/invariant.ts +++ b/packages/support/llm-replay/src/invariant.ts @@ -1,30 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-llm-replay`. @module @deepseek-ai/dsh-llm-replay/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-llm-replay`. + * @module @deepseek-ai/dsh-llm-replay/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-replay' /** Cordis companion plugin name. */ export const name = 'llm-replay-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'llm-replay', - inject: [ - 'llm', - ], - effects: [ - [ - 'llm.registerAdapter()', - 'ctx.on("llm/stream")', - ], - ], - }) -} +/** + * No runtime invariant: this test-only adapter consumes a fixed replay script; its stream grammar + * is checked by the LLM companion and fixture derivation tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 6fbb41a123..1e3cc54b81 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -1,32 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-loader-smoke. @module @deepseek-ai/dsh-loader-smoke/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-loader-smoke`. + * @module @deepseek-ai/dsh-loader-smoke/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-loader-smoke' /** Cordis companion plugin name. */ export const name = 'loader-smoke-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert default source mode and plain-Node built-artifact launch resolution. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') - assertInvariant(fail, resolveExampleMode('') === 'src', - 'an empty example-mode selection must preserve source-mode development') - const launch = resolveExampleLaunch({ - srcBin: '/workspace/probe/src/bin.ts', - mode: 'lib', - }) - assertInvariant(fail, - launch.command === process.execPath - && launch.args.length === 1 - && launch.args[0] === '/workspace/probe/lib/bin.js' - && launch.env.TSX_TSCONFIG_PATH === undefined, - 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') -} +/** + * No runtime invariant: this test-support package owns no production event stream or mutable data; + * consuming test suites exercise its behavior. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts index ce3bf24175..a633213607 100644 --- a/packages/tasks/tasks/src/invariant.ts +++ b/packages/tasks/tasks/src/invariant.ts @@ -1,31 +1,55 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tasks`. @module @deepseek-ai/dsh-tasks/invariant */ +/** Package-owned background-task snapshot invariants. @module @deepseek-ai/dsh-tasks/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { TaskSnapshot } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tasks' +const TERMINAL_STATUSES = new Set(['completed', 'killed', 'failed']) /** Cordis companion plugin name. */ export const name = 'tasks-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'TaskService', - effects: [ - 'ctx.provide("tasks")', - 'tasks teardown', - ], - services: [ - 'tasks', - ], - }) +/** Validate the cross-field relationships in one registry snapshot. */ +function validateSnapshot(snapshot: TaskSnapshot, owner: Agent | undefined, fail: InvariantFailure): void { + const id = String(snapshot.id) + const prefix = `${snapshot.kind}-` + const ordinal = Number(id.slice(prefix.length)) + if (snapshot.kind.length === 0 || !id.startsWith(prefix) + || !Number.isSafeInteger(ordinal) || ordinal < 1) { + fail(`task snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) + } + if (snapshot.label.length === 0) fail(`task ${JSON.stringify(id)} label must be non-empty`) + if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) { + fail(`task ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`) + } + + const terminal = TERMINAL_STATUSES.has(snapshot.status) + if (terminal !== (snapshot.finishedAt !== undefined)) { + fail(`task ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`) + } + if (snapshot.finishedAt !== undefined + && (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) { + fail(`task ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`) + } + + const expectedOwner = owner?.id + if (snapshot.ownerSession !== expectedOwner) { + fail(`task ${JSON.stringify(id)} ownerSession does not match its completion owner`) + } } +/** Install checks over current unowned records and every terminal snapshot. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const snapshot of ctx.tasks.list()) validateSnapshot(snapshot, undefined, fail) + ctx.tasks.onTaskDone((snapshot, owner) => { validateSnapshot(snapshot, owner, fail) }) +}, { inject: ['tasks'] }) + /** - * Register this package's invariant companion. + * Register the task-registry invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/tasks/tasks/tests/invariant.spec.ts b/packages/tasks/tasks/tests/invariant.spec.ts new file mode 100644 index 0000000000..e23609df5d --- /dev/null +++ b/packages/tasks/tasks/tests/invariant.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' +import type { TaskDoneListener, TaskSnapshot } from '@deepseek-ai/dsh-tasks' +import * as TasksInvariant from '@deepseek-ai/dsh-tasks/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +const BASE: TaskSnapshot = { + id: TaskId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'completed', + startedAt: 10, + finishedAt: 20, + reported: false, +} + +const RUNNING: TaskSnapshot = { + id: TaskId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'running', + startedAt: 10, + reported: false, +} + +const TERMINAL_WITHOUT_FINISH: TaskSnapshot = { + id: TaskId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'completed', + startedAt: 10, + reported: false, +} + +async function setup(seed: TaskSnapshot[] = []): Promise<(snapshot: unknown, owner?: Agent) => void> { + const ctx = new Context() + let listener: TaskDoneListener | undefined + const probe = { + list: () => seed, + onTaskDone(value: TaskDoneListener) { + listener = value + return () => { listener = undefined } + }, + } as unknown as TaskService + await ctx.plugin(InvariantService) + await ctx.plugin({ + name: 'task-invariant-probe', + apply(child: Context) { child.provide('tasks', probe) }, + }) + await ctx.plugin(TasksInvariant) + if (listener === undefined) throw new Error('task invariant did not subscribe to terminal snapshots') + return (snapshot, owner) => { listener!(snapshot as TaskSnapshot, owner) } +} + +describe('task-registry invariants', () => { + it('accepts coherent current and terminal snapshots', async () => { + const notify = await setup([RUNNING]) + expect(() => { notify(BASE) }).not.toThrow() + const owner = { id: SessionId('owner') } as Agent + expect(() => { notify({ ...BASE, id: TaskId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) }) + .not.toThrow() + }) + + it.each([ + [{ ...BASE, id: TaskId('-1'), kind: '' }, undefined, /positive ordinal/], + [{ ...BASE, id: TaskId('other-1') }, undefined, /must be "bash-" followed by a positive ordinal/], + [{ ...BASE, id: TaskId('bash-x') }, undefined, /positive ordinal/], + [{ ...BASE, id: TaskId('bash-0') }, undefined, /positive ordinal/], + [{ ...BASE, startedAt: -1 }, undefined, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, startedAt: 0.5 }, undefined, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, status: 'running' }, undefined, /finishedAt must be present exactly for a terminal status/], + [TERMINAL_WITHOUT_FINISH, undefined, /finishedAt must be present exactly for a terminal status/], + [{ ...BASE, finishedAt: 9 }, undefined, /no earlier than startedAt/], + [{ ...BASE, finishedAt: 20.5 }, undefined, /no earlier than startedAt/], + [{ ...BASE, ownerSession: SessionId('recorded') }, { id: SessionId('actual') } as Agent, /does not match its completion owner/], + ] as const)('rejects an incoherent registry snapshot', async (snapshot, owner, message) => { + const notify = await setup() + expect(() => { notify(snapshot, owner) }).toThrow(message) + }) + + it('rejects an incoherent record already present at installation', async () => { + await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/) + }) +}) diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts index e4af0931d2..cedad9dc1c 100644 --- a/packages/tasks/tool-tasks/src/invariant.ts +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-tasks`. @module @deepseek-ai/dsh-tool-tasks/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-tasks`. + * @module @deepseek-ai/dsh-tool-tasks/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-tasks' /** Cordis companion plugin name. */ export const name = 'tool-tasks-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-tasks', - inject: [ - 'tools', - 'tasks', - 'systemPrompt', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/timeout/timeout-policy/src/invariant.ts b/packages/timeout/timeout-policy/src/invariant.ts index b0e564dbbd..ddc3b3966e 100644 --- a/packages/timeout/timeout-policy/src/invariant.ts +++ b/packages/timeout/timeout-policy/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-timeout-policy`. @module @deepseek-ai/dsh-timeout-policy/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-timeout-policy`. + * @module @deepseek-ai/dsh-timeout-policy/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout-policy' /** Cordis companion plugin name. */ export const name = 'timeout-policy-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'timeout-policy', - inject: [ - 'tools', - ], - effects: [ - 'ctx.on("tools/execute")', - ], - }) -} +/** + * No runtime invariant: this stateless policy plugin owns no package-local event history or mutable + * data relation beyond the seam it intercepts. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 9ad57156f4..0b3a3739e4 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -1,30 +1,49 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-todo`. @module @deepseek-ai/dsh-tool-todo/invariant */ +/** Package-owned durable todo-snapshot invariants. @module @deepseek-ai/dsh-tool-todo/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-todo' +const TODO_STATUSES = new Set(['pending', 'in_progress', 'completed']) /** Cordis companion plugin name. */ export const name = 'tool-todo-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ +/** Validate one whole-list todo snapshot before it reaches the durable log. */ +function validateTodos(value: unknown, fail: InvariantFailure): void { + if (!Array.isArray(value)) fail('todo/write todos must be an array') + const seen = new Set() + let active = 0 + for (const item of value) { + if (typeof item !== 'object' || item === null) fail('todo/write entries must be objects') + const { content, status } = item as Record + if (typeof content !== 'string' || content.length === 0 || content.trim() !== content) { + fail('todo/write content must be non-empty and already trimmed') + } + if (seen.has(content)) fail(`todo/write repeats content ${JSON.stringify(content)}`) + seen.add(content) + if (typeof status !== 'string' || !TODO_STATUSES.has(status)) { + fail(`todo/write carries unknown status ${JSON.stringify(status)}`) + } + if (status === 'in_progress') active += 1 + } + if (active > 1) fail(`todo/write contains ${active} in-progress entries; at most one is allowed`) +} + +/** Install validation for durable whole-list todo snapshots. */ const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-todo', - inject: [ - 'tools', - ], - effects: [ - 'tools.register()', - ], - }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const event = (args as [Session, SessionEvent])[1] + if (event.type === 'todo/write') validateTodos(event.data.todos, fail) + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the todo invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts new file mode 100644 index 0000000000..8593f60299 --- /dev/null +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import * as TodoInvariant from '@deepseek-ai/dsh-tool-todo/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(TodoInvariant) + return ctx +} + +function event(todos: unknown): SessionEvent { + return { type: 'todo/write', seq: 0, time: 0, data: { todos } } as SessionEvent +} + +describe('todo snapshot invariants', () => { + it('accepts a unique whole-list snapshot with one active item', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, event([ + { content: 'Inspect state', status: 'completed' }, + { content: 'Apply fix', status: 'in_progress' }, + { content: 'Run checks', status: 'pending' }, + ])) }).not.toThrow() + }) + + it.each([ + ['not-an-array', /must be an array/], + [[null], /entries must be objects/], + [[42], /entries must be objects/], + [[{ content: 42, status: 'pending' }], /content must be non-empty/], + [[{ content: '', status: 'pending' }], /content must be non-empty/], + [[{ content: ' padded ', status: 'pending' }], /already trimmed/], + [[{ content: 'same', status: 'pending' }, { content: 'same', status: 'completed' }], /repeats content/], + [[{ content: 'task', status: 42 }], /unknown status/], + [[{ content: 'task', status: 'paused' }], /unknown status/], + [[{ content: 'one', status: 'in_progress' }, { content: 'two', status: 'in_progress' }], /at most one/], + ])('rejects an incoherent durable todo snapshot', async (todos, message) => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, event(todos)) }).toThrow(message) + }) + + it('ignores unrelated dispatches and session events', async () => { + const ctx = await setup() + expect(() => { + ctx.emit('tools/change') + ctx.emit('session/event', {} as Session, { + type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) + }).not.toThrow() + }) +}) diff --git a/packages/ui/acp/src/invariant.ts b/packages/ui/acp/src/invariant.ts index 1b98d6630c..fdefcf291e 100644 --- a/packages/ui/acp/src/invariant.ts +++ b/packages/ui/acp/src/invariant.ts @@ -1,34 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-acp`. @module @deepseek-ai/dsh-acp/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-acp`. + * @module @deepseek-ai/dsh-acp/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp' /** Cordis companion plugin name. */ export const name = 'acp-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'acp', - inject: [ - 'agents', - 'sessionPersistence', - 'tools', - 'userInteraction', - 'llm', - 'systemPrompt', - ], - effects: [ - 'userInteraction.registerProvider()', - 'ctx.on("session/event")', - 'acp.connection', - ], - }) -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -37,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts index 8591c60dd1..0dacba6e40 100644 --- a/packages/ui/app-boot/src/invariant.ts +++ b/packages/ui/app-boot/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-app-boot. @module @deepseek-ai/dsh-app-boot/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-app-boot`. + * @module @deepseek-ai/dsh-app-boot/invariant + */ /* jscpd:ignore-start */ -import { resolve } from 'node:path' import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot' /** Cordis companion plugin name. */ export const name = 'app-boot-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert ordinary and replay config-path selection. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { resolveConfigPath } = await import('./config-path.ts') - const cwd = '/tmp/dsh-app-boot-invariant' - const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) - const replay = resolveConfigPath('cordis.yml', 'replay', cwd) - assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), - 'ordinary app boot must retain the requested config basename') - assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), - 'snapshot replay must select cordis.snapshot.yml in the requested config directory') -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/ui/jsonrpc/src/invariant.ts b/packages/ui/jsonrpc/src/invariant.ts index aad2ac9f41..1a3c9b053b 100644 --- a/packages/ui/jsonrpc/src/invariant.ts +++ b/packages/ui/jsonrpc/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-jsonrpc`. @module @deepseek-ai/dsh-jsonrpc/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-jsonrpc`. + * @module @deepseek-ai/dsh-jsonrpc/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc' /** Cordis companion plugin name. */ export const name = 'jsonrpc-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'jsonrpc', - inject: [ - 'agents', - ], - effects: [ - 'jsonrpc.serve', - ], - }) -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts index 24191712bf..0be5942d24 100644 --- a/packages/ui/permission/src/invariant.ts +++ b/packages/ui/permission/src/invariant.ts @@ -1,34 +1,29 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-permission`. @module @deepseek-ai/dsh-permission/invariant */ +/** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-permission' /** Cordis companion plugin name. */ export const name = 'permission-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'PermissionService', - inject: [ - 'bash', - 'approval', - ], - effects: [ - 'ctx.provide("permission")', - ], - services: [ - 'permission', - ], - }) -} +/** Install validation that durable preset events remain resolvable. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: (message: string) => never) => { + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const event = (args as [Session, SessionEvent])[1] + if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) { + fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`) + } + }, { global: true }) +}, { inject: ['permission'] }) /** - * Register this package's invariant companion. + * Register the permission invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/ui/permission/tests/invariant.spec.ts b/packages/ui/permission/tests/invariant.spec.ts new file mode 100644 index 0000000000..6488239d38 --- /dev/null +++ b/packages/ui/permission/tests/invariant.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { Context, Service } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +class PermissionProbe extends Service { + readonly names = ['safe', 'trusted'] + + constructor(ctx: Context) { + super(ctx, 'permission') + } +} + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(PermissionProbe) + await ctx.plugin(InvariantService) + await ctx.plugin(PermissionInvariant) + return ctx +} + +function presetEvent(preset: string): SessionEvent { + return { type: 'permission/preset', seq: 0, time: 0, data: { preset } } +} + +describe('permission invariants', () => { + it('accepts configured preset events and ignores other session data', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, presetEvent('safe')) }).not.toThrow() + expect(() => { ctx.emit('session/event', {} as Session, { + type: 'turn/end', seq: 0, time: 0, data: {}, + } as SessionEvent) }).not.toThrow() + expect(() => { ctx.emit('tools/change') }).not.toThrow() + }) + + it('rejects a durable preset that the active table cannot resolve', async () => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, presetEvent('missing')) }) + .toThrow(/unknown preset "missing"/) + }) +}) diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts index 56bf12cda3..31c3e78e7d 100644 --- a/packages/ui/stdio/src/invariant.ts +++ b/packages/ui/stdio/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-stdio`. @module @deepseek-ai/dsh-stdio/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-stdio`. + * @module @deepseek-ai/dsh-stdio/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-stdio' /** Cordis companion plugin name. */ export const name = 'stdio-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'ui-stdio', - inject: [ - 'agents', - 'userInteraction', - ], - effects: [ - 'ctx.on("session/event")', - 'userInteraction.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/tool-ask-user/src/invariant.ts b/packages/ui/tool-ask-user/src/invariant.ts index 2b18cad1f3..140bbd79c5 100644 --- a/packages/ui/tool-ask-user/src/invariant.ts +++ b/packages/ui/tool-ask-user/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-ask-user`. @module @deepseek-ai/dsh-tool-ask-user/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-ask-user`. + * @module @deepseek-ai/dsh-tool-ask-user/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user' /** Cordis companion plugin name. */ export const name = 'tool-ask-user-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-ask-user', - inject: [ - 'tools', - 'userInteraction', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -31,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/tui/src/invariant.ts b/packages/ui/tui/src/invariant.ts index d3014146d7..f8072f3968 100644 --- a/packages/ui/tui/src/invariant.ts +++ b/packages/ui/tui/src/invariant.ts @@ -1,30 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tui`. @module @deepseek-ai/dsh-tui/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tui`. + * @module @deepseek-ai/dsh-tui/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tui' /** Cordis companion plugin name. */ export const name = 'tui-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'ui-tui', - inject: [ - 'agents', - 'userInteraction', - 'tools', - ], - effects: [ - 'ctx.on("session/event")', - 'userInteraction.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this presentation adapter owns no durable package-local event stream; + * boundary and replay tests cover its protocol mapping. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 9a905be06f..9fc42fa908 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -1,31 +1,88 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-user-approval`. @module @deepseek-ai/dsh-user-approval/invariant */ +/** Package-owned approval audit-stream invariants. @module @deepseek-ai/dsh-user-approval/invariant */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ApprovalRequestId } from './index.ts' +import { APPROVAL_POLICIES } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-user-approval' +const APPROVAL_OUTCOMES = ['allowed-once', 'rejected', 'cancelled', 'unavailable'] as const /** Cordis companion plugin name. */ export const name = 'user-approval-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'ApprovalService', - effects: [ - 'ctx.provide("approval")', - 'ctx.on("agent/pre-step")', - ], - services: [ - 'approval', - ], - }) +type ApprovalTransition = + | { kind: 'asked'; id: ApprovalRequestId } + | { kind: 'decided'; id: ApprovalRequestId } + +/** Validate one approval event against committed unmatched questions. */ +function validateApprovalEvent( + pending: ReadonlySet, + event: SessionEvent, + fail: InvariantFailure, +): ApprovalTransition | undefined { + if (event.type === 'approval/asked') { + if (event.data.toolName.length === 0) fail('approval/asked toolName must be non-empty') + if (pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`) + return { kind: 'asked', id: event.data.id } + } + if (event.type === 'approval/decided') { + if (!pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`) + if (!APPROVAL_OUTCOMES.includes(event.data.outcome)) { + fail(`approval/decided carries unknown outcome ${JSON.stringify(event.data.outcome)}`) + } + return { kind: 'decided', id: event.data.id } + } + if (event.type === 'approval/policy' && !APPROVAL_POLICIES.includes(event.data.policy)) { + fail(`approval/policy carries unknown policy ${JSON.stringify(event.data.policy)}`) + } + return undefined } +/** Apply one accepted approval-pair transition. */ +function applyApprovalTransition(pending: Set, transition: ApprovalTransition): void { + if (transition.kind === 'asked') pending.add(transition.id) + else pending.delete(transition.id) +} + +/** Install audit pairing and closed-vocabulary checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap>() + const staged = new WeakMap() + const seed = (session: Session): Set => { + const pending = new Set() + traces.set(session, pending) + for (const event of session.events) { + const transition = validateApprovalEvent(pending, event, fail) + if (transition !== undefined) applyApprovalTransition(pending, transition) + } + return pending + } + const traceFor = (session: Session): Set => traces.get(session) ?? seed(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return + const candidate = staged.get(event) + /* v8 ignore next -- internal/dispatch stages every package-owned pair event */ + if (candidate === undefined || candidate.session !== session) return fail('approval audit event published without pre-commit validation') + staged.delete(event) + applyApprovalTransition(traceFor(session), candidate.transition) + }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const transition = validateApprovalEvent(traceFor(session), event, fail) + if (transition !== undefined) staged.set(event, { session, transition }) + }, { global: true }) +}, { inject: ['sessions'] }) + /** - * Register this package's invariant companion. + * Register the approval invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/ui/user-approval/tests/invariant.spec.ts b/packages/ui/user-approval/tests/invariant.spec.ts new file mode 100644 index 0000000000..ea9d4472e5 --- /dev/null +++ b/packages/ui/user-approval/tests/invariant.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' +import * as ApprovalInvariant from '@deepseek-ai/dsh-user-approval/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(ApprovalInvariant) + return ctx +} + +describe('approval invariants', () => { + it('accepts paired audit events and closed policy values', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const id = ApprovalRequestId('ask-1') + session.append('approval/asked', { id, toolName: 'bash' }) + session.append('approval/decided', { id, outcome: 'allowed-once' }) + session.append('approval/policy', { policy: 'never' }) + }) + + it('rebuilds an unmatched question from an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const id = ApprovalRequestId('ask-resume') + session.append('approval/asked', { id, toolName: 'bash' }) + await ctx.plugin(InvariantService) + await ctx.plugin(ApprovalInvariant) + expect(() => session.append('approval/decided', { id, outcome: 'cancelled' })).not.toThrow() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + + it('adopts a bare session first observed through publication', async () => { + const ctx = await setup() + const session = new Session(SessionId('bare-approval-session')) + const id = ApprovalRequestId('bare-ask') + const asked = { + type: 'approval/asked', seq: 0, time: 0, data: { id, toolName: 'bash' }, + } as const + const decided = { + type: 'approval/decided', seq: 1, time: 1, data: { id, outcome: 'rejected' as const }, + } as const + expect(() => { + ctx.emit('session/event', session, asked) + ctx.emit('session/event', session, decided) + }).not.toThrow() + }) + + it('rejects malformed and unpaired audit events', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const id = ApprovalRequestId('ask-1') + expect(() => session.append('approval/asked', { id, toolName: '' })) + .toThrow(/toolName must be non-empty/) + session.append('approval/asked', { id, toolName: 'bash' }) + expect(() => session.append('approval/asked', { id, toolName: 'bash' })) + .toThrow(/repeated open id/) + expect(() => session.append('approval/decided', { + id: ApprovalRequestId('missing'), outcome: 'rejected', + })).toThrow(/no matching approval\/asked/) + expect(() => session.append('approval/decided', { id, outcome: 'maybe' as never })) + .toThrow(/unknown outcome/) + expect(() => session.append('approval/policy', { policy: 'always' as never })) + .toThrow(/unknown policy/) + }) +}) diff --git a/packages/ui/user-interaction/src/invariant.ts b/packages/ui/user-interaction/src/invariant.ts index 85029b692b..f4f2f2f31e 100644 --- a/packages/ui/user-interaction/src/invariant.ts +++ b/packages/ui/user-interaction/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-user-interaction`. @module @deepseek-ai/dsh-user-interaction/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-user-interaction`. + * @module @deepseek-ai/dsh-user-interaction/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction' /** Cordis companion plugin name. */ export const name = 'user-interaction-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'UserInteractionService', - effects: [ - 'ctx.provide("userInteraction")', - ], - services: [ - 'userInteraction', - ], - }) -} +/** + * No runtime invariant: the single provider slot is validated at registration and asks return + * directly to their caller; the seam publishes no independent request/answer audit stream. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index d632921d35..bf29a81b4c 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -1,22 +1,24 @@ -/** Package-owned runtime contract for @deepseek-ai/dsh-brand. @module @deepseek-ai/dsh-brand/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-brand`. + * @module @deepseek-ai/dsh-brand/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-brand' /** Cordis companion plugin name. */ export const name = 'brand-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert that the nominal-type primitive remains erased at runtime. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const brandRuntime = await import('./index.ts') - assertInvariant(fail, Object.keys(brandRuntime).length === 0, - 'the branded-id primitive must remain type-only with no runtime exports') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts index fc7c9c3129..dbd8e38f51 100644 --- a/packages/util/home/src/invariant.ts +++ b/packages/util/home/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-home. @module @deepseek-ai/dsh-home/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-home`. + * @module @deepseek-ai/dsh-home/invariant + */ /* jscpd:ignore-start */ -import { resolve } from 'node:path' import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-home' /** Cordis companion plugin name. */ export const name = 'home-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert the canonical environment key and configured-path precedence. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') - const environmentKey: string = DSH_HOME_ENV - assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), - 'the canonical Harness home environment key must remain DSH_HOME') - const configured = 'relative-invariant-home' - assertInvariant(fail, resolveDshHome(configured) === resolve(configured), - 'an explicitly configured Harness home must normalize to an absolute path') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index 20a3962ff4..f1661b7f52 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-paths. @module @deepseek-ai/dsh-paths/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-paths`. + * @module @deepseek-ai/dsh-paths/invariant + */ /* jscpd:ignore-start */ -import { homedir } from 'node:os' -import { join, resolve } from 'node:path' import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-paths' /** Cordis companion plugin name. */ export const name = 'paths-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert tilde expansion and explicit-over-environment home precedence. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') - assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), - 'supported tilde prefixes must expand against the operating-system home') - const configured = 'relative-invariant-home' - const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) - assertInvariant(fail, resolved === resolve(configured), - 'an explicit DSH home must override the environment and normalize to an absolute path') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index fa8ab7d36a..0365793b03 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -1,33 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-retention. @module @deepseek-ai/dsh-retention/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-retention`. + * @module @deepseek-ai/dsh-retention/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-retention' /** Cordis companion plugin name. */ export const name = 'retention-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert exact head-retention accounting after the budget is exceeded. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { ItemRetainer } = await import('./index.ts') - const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) - retainer.push('first') - retainer.push('second') - retainer.push('third') - const result = retainer.finish() - assertInvariant(fail, - result.items.join(',') === 'first,second' - && result.seen === 3 - && result.kept === 2 - && result.truncated - && result.omitted.kind === 'exact' - && result.omitted.count === 1, - 'head retention must keep the prefix and report exact seen, kept, and omitted counts') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index 2d9d22f5d6..bb9604d6b7 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -1,28 +1,24 @@ -/** Package-owned runtime contracts for @deepseek-ai/dsh-timeout. @module @deepseek-ai/dsh-timeout/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-timeout`. + * @module @deepseek-ai/dsh-timeout/invariant + */ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import { assertInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout' /** Cordis companion plugin name. */ export const name = 'timeout-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert default-before-cap arithmetic and capability-code classification. */ -const install: InvariantInstaller = async (_ctx, fail) => { - const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') - assertInvariant(fail, - clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, - 'timeout resolution must apply the default before capping and preserve smaller requests') - const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) - assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, - 'timeout classification must recover a matching capability-owned reason') - assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, - 'timeout classification must reject a reason owned by another capability') -} +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value + * algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts index 1cceb0f166..435f9ca549 100644 --- a/packages/web/tool-web/src/invariant.ts +++ b/packages/web/tool-web/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-web`. @module @deepseek-ai/dsh-tool-web/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-web`. + * @module @deepseek-ai/dsh-tool-web/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-web' /** Cordis companion plugin name. */ export const name = 'tool-web-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-web', - inject: [ - 'tools', - 'web', - 'systemPrompt', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts index 5a29395715..053fb12200 100644 --- a/packages/web/web-fetch-local/src/invariant.ts +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web-fetch-local`. @module @deepseek-ai/dsh-web-fetch-local/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-web-fetch-local`. + * @module @deepseek-ai/dsh-web-fetch-local/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-fetch-local' /** Cordis companion plugin name. */ export const name = 'web-fetch-local-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'web-fetch-local', - inject: [ - 'web', - ], - effects: [ - 'web.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts index bfed9260dc..8b79315f7d 100644 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -1,30 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-deepseek`. + * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-deepseek`. * @module @deepseek-ai/dsh-web-search-deepseek/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-deepseek' /** Cordis companion plugin name. */ export const name = 'web-search-deepseek-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'web-search-deepseek', - inject: [ - 'web', - ], - effects: [ - 'web.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts index 8d5da8d433..060ceb78ba 100644 --- a/packages/web/web-search-exa/src/invariant.ts +++ b/packages/web/web-search-exa/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-exa`. @module @deepseek-ai/dsh-web-search-exa/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-exa`. + * @module @deepseek-ai/dsh-web-search-exa/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-exa' /** Cordis companion plugin name. */ export const name = 'web-search-exa-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'web-search-exa', - inject: [ - 'web', - ], - effects: [ - 'web.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts index 7669f6e53a..cf3e009fed 100644 --- a/packages/web/web-search-perplexity/src/invariant.ts +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -1,30 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-web-search-perplexity`. + * Package-owned invariant companion for `@deepseek-ai/dsh-web-search-perplexity`. * @module @deepseek-ai/dsh-web-search-perplexity/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-perplexity' /** Cordis companion plugin name. */ export const name = 'web-search-perplexity-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'web-search-perplexity', - inject: [ - 'web', - ], - effects: [ - 'web.registerProvider()', - ], - }) -} +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -33,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts index 395679cf36..2ac094b34f 100644 --- a/packages/web/web/src/invariant.ts +++ b/packages/web/web/src/invariant.ts @@ -1,27 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-web`. @module @deepseek-ai/dsh-web/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-web`. + * @module @deepseek-ai/dsh-web/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web' /** Cordis companion plugin name. */ export const name = 'web-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'WebService', - effects: [ - 'ctx.provide("web")', - ], - services: [ - 'web', - ], - }) -} +/** + * No runtime invariant: provider maps are private and selection/result caps are enforced on each + * call; the seam publishes no independent registry or request/result observation stream. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -30,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 966e4b3613..5f3ebc68ce 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -1,29 +1,24 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-tool-workflow`. @module @deepseek-ai/dsh-tool-workflow/invariant */ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-workflow`. + * @module @deepseek-ai/dsh-tool-workflow/invariant + */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' /** Cordis companion plugin name. */ export const name = 'tool-workflow-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'tool-workflow', - inject: [ - 'tools', - 'workflows', - 'systemPrompt', - ], - effects: [ - 'tools.register()', - ], - }) -} +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution + * relations are owned by the capability seam it calls. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -32,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts index 27c0865d79..6845aeebff 100644 --- a/packages/workflow/workflow-workerthread/src/invariant.ts +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -1,33 +1,24 @@ /** - * Package-owned runtime contract checks for `@deepseek-ai/dsh-workflow-workerthread`. + * Package-owned invariant companion for `@deepseek-ai/dsh-workflow-workerthread`. * @module @deepseek-ai/dsh-workflow-workerthread/invariant */ +/* jscpd:ignore-start */ import type { Context } from 'cordis' -import { observePluginInvariant, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-workerthread' /** Cordis companion plugin name. */ export const name = 'workflow-workerthread-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install checks for this package's active plugin fibers. */ -const install: InvariantInstaller = (ctx, fail) => { - observePluginInvariant(ctx, fail, { - name: 'WorkerWorkflowEngine', - inject: [ - 'subagents', - ], - effects: [ - 'ctx.provide("workflows")', - ], - services: [ - 'workflows', - ], - }) -} +/** + * No runtime invariant: this process-boundary implementation exposes no same-process event relation; + * worker protocol and built-worker tests cover it. + */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -36,3 +27,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts index a37d480e69..f6b8b8ced2 100644 --- a/packages/workflow/workflow/src/invariant.ts +++ b/packages/workflow/workflow/src/invariant.ts @@ -1,24 +1,134 @@ -/** Package-owned runtime contract checks for `@deepseek-ai/dsh-workflow`. @module @deepseek-ai/dsh-workflow/invariant */ +/** Package-owned workflow lifecycle invariants. @module @deepseek-ai/dsh-workflow/invariant */ import type { Context } from 'cordis' -import { observeServiceInvariant, serviceShapeViolation, type InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { + WorkflowAgentEndInfo, + WorkflowAgentInfo, + WorkflowResultInfo, + WorkflowRunInfo, +} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow' /** Cordis companion plugin name. */ export const name = 'workflow-invariant' -/** Services required before the companion can register. */ +/** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate every implementation bound to this package's service seam. */ +interface WorkflowTrace { + meta: string + agents: Map + starts: number +} + +/** Require every event for a run to retain its validated identity snapshot. */ +function traceFor( + traces: ReadonlyMap, + info: WorkflowRunInfo, + fail: InvariantFailure, +): WorkflowTrace { + const trace = traces.get(info.id) + if (trace === undefined) fail(`workflow event has no matching workflow/start for run ${JSON.stringify(info.id)}`) + if (trace.meta !== JSON.stringify(info.meta)) { + fail(`workflow event meta diverges from workflow/start for run ${JSON.stringify(info.id)}`) + } + return trace +} + +/** Assert the immutable identity fields shared by an agent pair. */ +function validateAgentEnd(start: WorkflowAgentInfo, end: WorkflowAgentEndInfo, fail: InvariantFailure): void { + if (start.label !== end.label || start.phase !== end.phase || start.childId !== end.childId) { + fail(`workflow/agent-end identity diverges from workflow/agent-start for seq ${end.seq}`) + } + const outcome: string = end.outcome + if (outcome !== 'completed' && outcome !== 'failed' && outcome !== 'cancelled') { + fail(`workflow/agent-end carries unknown outcome ${JSON.stringify(outcome)}`) + } +} + +/** Validate a terminal result against the accumulated run trace. */ +function validateWorkflowEnd(trace: WorkflowTrace, result: WorkflowResultInfo, fail: InvariantFailure): void { + if (trace.agents.size > 0) fail(`workflow/end has ${trace.agents.size} agent call(s) without workflow/agent-end`) + if (!Number.isSafeInteger(result.agentsStarted) || result.agentsStarted < trace.starts) { + fail('workflow/end agentsStarted must be a safe integer covering every observed agent start') + } + if (result.stopReason === 'completed' ? result.error !== undefined : typeof result.error !== 'string') { + fail('workflow/end error must be absent exactly for completed runs') + } +} + +/** Install workflow start/end and child-call pairing checks. */ const install: InvariantInstaller = (ctx, fail) => { - observeServiceInvariant(ctx, fail, 'workflows', value => serviceShapeViolation(value, { - methods: ['start'], - })) + const traces = new Map() + const stagedStarts = new WeakSet() + const stagedAgentStarts = new WeakSet() + const stagedAgentEnds = new WeakSet() + const stagedEnds = new WeakSet() + + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName === 'workflow/start') { + const info = args[0] as WorkflowRunInfo + if (String(info.id).length === 0 || info.meta.name.length === 0 || info.meta.description.length === 0) { + fail('workflow/start id, meta.name, and meta.description must be non-empty') + } + if (traces.has(info.id)) fail(`workflow/start repeated run id ${JSON.stringify(info.id)}`) + stagedStarts.add(info) + return + } + if (!eventName.startsWith('workflow/')) return + const info = args[0] as WorkflowRunInfo + const trace = traceFor(traces, info, fail) + if (eventName === 'workflow/agent-start') { + const agent = args[1] as WorkflowAgentInfo + if (!Number.isSafeInteger(agent.seq) || agent.seq < 1 || String(agent.childId).length === 0) { + fail('workflow/agent-start seq must be positive and childId must be non-empty') + } + if (trace.agents.has(agent.seq)) fail(`workflow/agent-start repeated seq ${agent.seq}`) + stagedAgentStarts.add(agent) + return + } + if (eventName === 'workflow/agent-end') { + const agent = args[1] as WorkflowAgentEndInfo + const start = trace.agents.get(agent.seq) + if (start === undefined) return fail(`workflow/agent-end has no matching start for seq ${agent.seq}`) + validateAgentEnd(start, agent, fail) + stagedAgentEnds.add(agent) + return + } + if (eventName === 'workflow/end') { + const result = args[1] as WorkflowResultInfo + validateWorkflowEnd(trace, result, fail) + stagedEnds.add(result) + } + }, { global: true }) + + ctx.on('workflow/start', (info) => { + /* v8 ignore next -- internal/dispatch stages the same run-info object */ + if (!stagedStarts.delete(info)) return + traces.set(info.id, { meta: JSON.stringify(info.meta), agents: new Map(), starts: 0 }) + }, { global: true }) + ctx.on('workflow/agent-start', (info, agent) => { + /* v8 ignore next -- internal/dispatch stages the same agent object */ + if (!stagedAgentStarts.delete(agent)) return + const trace = traceFor(traces, info, fail) + trace.agents.set(agent.seq, agent) + trace.starts += 1 + }, { global: true }) + ctx.on('workflow/agent-end', (info, agent) => { + /* v8 ignore next -- internal/dispatch stages the same agent object */ + if (!stagedAgentEnds.delete(agent)) return + traceFor(traces, info, fail).agents.delete(agent.seq) + }, { global: true }) + ctx.on('workflow/end', (info, result) => { + /* v8 ignore next -- internal/dispatch stages the same result object */ + if (!stagedEnds.delete(result)) return + traces.delete(info.id) + }, { global: true }) } /** - * Register this package's invariant companion. + * Register the workflow invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/workflow/workflow/tests/invariant.spec.ts b/packages/workflow/workflow/tests/invariant.spec.ts new file mode 100644 index 0000000000..671a7a3e86 --- /dev/null +++ b/packages/workflow/workflow/tests/invariant.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import { WorkflowRunId } from '@deepseek-ai/dsh-workflow' +import type { + WorkflowAgentEndInfo, + WorkflowAgentInfo, + WorkflowResultInfo, + WorkflowRunInfo, +} from '@deepseek-ai/dsh-workflow' +import * as WorkflowInvariant from '@deepseek-ai/dsh-workflow/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(WorkflowInvariant) + return ctx +} + +const info = (overrides: Partial = {}): WorkflowRunInfo => ({ + id: WorkflowRunId('workflow-1'), + meta: { name: 'review', description: 'Review a change' }, + ...overrides, +}) + +const agent = (overrides: Partial = {}): WorkflowAgentInfo => ({ + seq: 1, + label: 'reviewer', + childId: SessionId('child-1'), + ...overrides, +}) + +const agentEnd = (overrides: Partial = {}): WorkflowAgentEndInfo => ({ + ...agent(), + outcome: 'completed', + ...overrides, +}) + +const result = (overrides: Partial = {}): WorkflowResultInfo => ({ + stopReason: 'completed', + agentsStarted: 1, + ...overrides, +}) + +describe('workflow invariants', () => { + it('accepts a complete workflow and child lifecycle', async () => { + const ctx = await setup() + const run = info() + ctx.emit('workflow/start', run) + ctx.emit('workflow/phase', run, 'inspect') + ctx.emit('workflow/log', run, 'working') + ctx.emit('workflow/agent-start', run, agent()) + ctx.emit('workflow/agent-end', run, agentEnd()) + ctx.emit('workflow/end', run, result()) + ctx.emit('tools/change') + }) + + it('rejects invalid run identity and enclosure', async () => { + const ctx = await setup() + expect(() => { ctx.emit('workflow/start', info({ id: WorkflowRunId('') })) }).toThrow(/must be non-empty/) + const run = info() + ctx.emit('workflow/start', run) + expect(() => { ctx.emit('workflow/start', run) }).toThrow(/repeated run id/) + expect(() => { ctx.emit('workflow/log', info({ meta: { name: 'other', description: 'x' } }), 'x') }) + .toThrow(/meta diverges/) + const fresh = await setup() + expect(() => { fresh.emit('workflow/log', info(), 'x') }).toThrow(/no matching workflow\/start/) + }) + + it('rejects malformed and unpaired child lifecycles', async () => { + const ctx = await setup() + const run = info() + ctx.emit('workflow/start', run) + expect(() => { ctx.emit('workflow/agent-start', run, agent({ seq: 0 })) }).toThrow(/seq must be positive/) + ctx.emit('workflow/agent-start', run, agent()) + expect(() => { ctx.emit('workflow/agent-start', run, agent()) }).toThrow(/repeated seq/) + expect(() => { ctx.emit('workflow/agent-end', run, agentEnd({ seq: 2 })) }).toThrow(/no matching start/) + expect(() => { ctx.emit('workflow/agent-end', run, agentEnd({ childId: SessionId('other') })) }) + .toThrow(/identity diverges/) + expect(() => { ctx.emit('workflow/agent-end', run, agentEnd({ outcome: 'unknown' as never })) }) + .toThrow(/unknown outcome/) + }) + + it('rejects inconsistent terminal results', async () => { + const active = await setup() + active.emit('workflow/start', info()) + active.emit('workflow/agent-start', info(), agent()) + expect(() => { active.emit('workflow/end', info(), result()) }).toThrow(/without workflow\/agent-end/) + + const count = await setup() + count.emit('workflow/start', info()) + count.emit('workflow/agent-start', info(), agent()) + count.emit('workflow/agent-end', info(), agentEnd()) + expect(() => { count.emit('workflow/end', info(), result({ agentsStarted: 0 })) }) + .toThrow(/covering every observed agent start/) + + const completed = await setup() + completed.emit('workflow/start', info()) + expect(() => { completed.emit('workflow/end', info(), result({ error: 'unexpected' })) }) + .toThrow(/absent exactly for completed/) + + const failed = await setup() + failed.emit('workflow/start', info()) + expect(() => { failed.emit('workflow/end', info(), result({ stopReason: 'error' })) }) + .toThrow(/absent exactly for completed/) + }) +}) diff --git a/packages/workflow/workflow/tests/workflow.spec.ts b/packages/workflow/workflow/tests/workflow.spec.ts index 0df3824071..01413ab75f 100644 --- a/packages/workflow/workflow/tests/workflow.spec.ts +++ b/packages/workflow/workflow/tests/workflow.spec.ts @@ -58,8 +58,11 @@ describe('dsh-workflow (interface)', () => { ctx.on('workflow/log', (info, message) => { seen.push([info, message]) }) ctx.on('workflow/agent-start', (info, agent) => { seen.push([info, agent]) }) const engine = ctx.workflows as StubEngine + engine.emit('workflow/start', INFO) engine.emit('workflow/log', INFO, 'hello') engine.emit('workflow/agent-start', INFO, { seq: 1, label: 'l', childId: 'c' }) + engine.emit('workflow/agent-end', INFO, { seq: 1, label: 'l', childId: 'c', outcome: 'completed' }) + engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 1 }) expect(seen).toEqual([ [INFO, 'hello'], [INFO, { seq: 1, label: 'l', childId: 'c' }], @@ -77,8 +80,11 @@ describe('dsh-workflow (interface)', () => { ctx.on('workflow/agent-start', (_info, agent) => { seen.push(agent.label) }) const engine = ctx.workflows as StubEngine const payload = { seq: 1, label: 'original', childId: 'c' } + engine.emit('workflow/start', INFO) engine.emit('workflow/agent-start', INFO, payload) await Promise.resolve() + engine.emit('workflow/agent-end', INFO, { ...payload, outcome: 'completed' }) + engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 1 }) expect(seen).toEqual(['original']) expect(String(warn.mock.calls[0]![0])).toContain('listener rejected') }) @@ -91,7 +97,9 @@ describe('dsh-workflow (interface)', () => { ctx.on('workflow/phase', () => { throw new Error('bad listener') }) ctx.on('workflow/phase', (_info, title) => { reached.push(title) }) const engine = ctx.workflows as StubEngine + engine.emit('workflow/start', INFO) expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow() + engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 0 }) expect(reached).toEqual(['Scan']) expect(warn).toHaveBeenCalledOnce() expect(String(warn.mock.calls[0]![0])).toContain('workflow/phase listener threw') @@ -107,7 +115,9 @@ describe('dsh-workflow (interface)', () => { }) ctx.on('workflow/phase', (_info, title) => { reached.push(title) }) const engine = ctx.workflows as StubEngine + engine.emit('workflow/start', INFO) expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow() + engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 0 }) expect(reached).toEqual(['Scan']) expect(warn).toHaveBeenCalledOnce() expect(String(warn.mock.calls[0]![0])).toContain('[unrenderable thrown value]') diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 9995d078bf..1014ce5033 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -16,8 +16,10 @@ function handwrittenInvariant(packageName: string): string { return ` export const name = 'probe-invariant' export const inject = ['invariants'] -const install = (_ctx: unknown, fail: (message: string) => never) => { - if (typeof ${JSON.stringify(packageName)} !== 'string') fail('package name must remain a string') +const install = (ctx: { on(name: string, listener: (value: number) => void): void }, fail: (message: string) => never) => { + ctx.on('probe/value', (value) => { + if (value < 0) fail('observed values must be non-negative') + }) } export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install)) @@ -65,41 +67,6 @@ function fixture(options: { return root } -function addConformingPackage(root: string, slug: string, packageName: string, source: string): void { - const dir = join(root, `packages/core/${slug}`) - mkdirSync(join(dir, 'src'), { recursive: true }) - writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ - name: packageName, - exports: { - './invariant': { - types: './lib/types/invariant.d.ts', - default: './lib/invariant.js', - }, - }, - files: ['lib/invariant.js'], - peerDependencies: { '@deepseek-ai/dsh-invariants': '^0.0.1' }, - devDependencies: { '@deepseek-ai/dsh-invariants': 'workspace:^' }, - }, null, 2)}\n`) - writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ - references: [{ path: '../../support/invariants' }], - }, null, 2)}\n`) - writeFileSync(join(dir, 'src/invariant.ts'), source) - writeFileSync(join(dir, 'tsdown.config.ts'), "export default { entry: ['lib/types/invariant.js'] }\n") -} - -function nameObservedInvariant(packageName: string, pluginName: string): string { - return ` -import { observePluginInvariant } from '@deepseek-ai/dsh-invariants' -export const name = 'probe-invariant' -export const inject = ['invariants'] -const install = (ctx: never, fail: (message: string) => never) => { - observePluginInvariant(ctx, fail, { name: ${JSON.stringify(pluginName)} }) -} -export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => - Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install)) -` -} - describe('package invariant gate', () => { it('accepts a hand-owned checking companion with publication metadata', () => { expect(collectPackageInvariantViolations(fixture())).toEqual([]) @@ -139,27 +106,24 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo ])) }) - it('rejects generated markers and empty or reporter-free installers', () => { + it('rejects generated markers and reporter-free executable installers', () => { const generated = fixture({ source: `/** @generated */\n${handwrittenInvariant('@deepseek-ai/dsh-probe')}`, }) expect(collectPackageInvariantViolations(generated).map(violation => violation.message)) .toContain('invariant companions must be hand-owned and may not carry @generated markers') - const empty = fixture({ + const reporterFree = fixture({ source: ` export const name = 'probe-invariant' export const inject = ['invariants'] -const install = () => {} +const install = () => { void 0 } export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install)) `, }) - expect(collectPackageInvariantViolations(empty).map(violation => violation.message)) - .toEqual(expect.arrayContaining([ - 'install function must contain a package-owned invariant check', - 'install function must accept the bound failure reporter as its second parameter', - ])) + expect(collectPackageInvariantViolations(reporterFree).map(violation => violation.message)) + .toContain('install function must accept the bound failure reporter as its second parameter') const unused = fixture({ source: ` @@ -174,22 +138,19 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo .toContain('install function must use its bound failure reporter') }) - it('rejects duplicate name-based plugin observers across packages', () => { - const root = fixture({ - source: nameObservedInvariant('@deepseek-ai/dsh-probe', 'shared-runtime-name'), - }) - addConformingPackage( - root, - 'probe-two', - '@deepseek-ai/dsh-probe-two', - nameObservedInvariant('@deepseek-ai/dsh-probe-two', 'shared-runtime-name'), - ) - expect(collectPackageInvariantViolations(root).map(violation => violation.message)) - .toContain('name-based plugin invariant "shared-runtime-name" is already owned by "@deepseek-ai/dsh-probe-two"') - }) + it('accepts explained empty installers and rejects unexplained ones', () => { + const explained = ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const PACKAGE_NAME = '@deepseek-ai/dsh-probe' +/** No runtime invariant: this pure package owns no events or mutable data. */ +const install = () => {} +export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => + ctx.invariants.register(PACKAGE_NAME, install) +` + expect(collectPackageInvariantViolations(fixture({ source: explained }))).toEqual([]) - it('rejects an unexplained empty package installer', () => { - const source = ` + const unexplained = ` export const name = 'probe-invariant' export const inject = ['invariants'] const PACKAGE_NAME = '@deepseek-ai/dsh-probe' @@ -197,7 +158,7 @@ const install = () => {} export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => ctx.invariants.register(PACKAGE_NAME, install) ` - expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message)) + expect(collectPackageInvariantViolations(fixture({ source: unexplained })).map(violation => violation.message)) .toContain('empty install function must explain why with a "No runtime invariant:" comment') }) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 6448888680..4600d04ee3 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -8,6 +8,9 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { dirname, relative, resolve, sep } from 'node:path' import ts from 'typescript' +/** Required explanation marker for an intentionally empty installer. */ +export const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' + interface PackageManifest { name?: string exports?: Record @@ -53,23 +56,11 @@ export function packageInvariantOwners(root: string): PackageInvariantOwner[] { /** Return all violations of the package-invariant companion contract. */ export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { const violations: PackageInvariantViolation[] = [] - const observedPluginNames = new Map() for (const owner of packageInvariantOwners(root)) { const manifest = readManifest(resolve(root, owner.manifestPath)) checkManifest(owner, manifest, violations) checkBuild(owner, root, violations) - for (const pluginName of checkSource(owner, root, violations)) { - const existing = observedPluginNames.get(pluginName) - if (existing === undefined) { - observedPluginNames.set(pluginName, owner) - } else { - addViolation( - violations, - owner.sourcePath, - `name-based plugin invariant ${JSON.stringify(pluginName)} is already owned by ${JSON.stringify(existing.packageName)}`, - ) - } - } + checkSource(owner, root, violations) } return violations } @@ -151,11 +142,11 @@ function checkSource( owner: PackageInvariantOwner, root: string, violations: PackageInvariantViolation[], -): string[] { +): void { const absolutePath = resolve(root, owner.sourcePath) if (!existsSync(absolutePath)) { addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion') - return [] + return } const sourceText = readFileSync(absolutePath, 'utf8') if (sourceText.includes('@generated')) { @@ -206,49 +197,26 @@ function checkSource( addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) } } - checkInstaller(owner, sourceFile, violations) - return nameOnlyObservedPlugins(sourceFile) -} - -function nameOnlyObservedPlugins(sourceFile: ts.SourceFile): string[] { - const names: string[] = [] - const visit = (node: ts.Node): void => { - if (ts.isCallExpression(node) - && ts.isIdentifier(node.expression) - && node.expression.text === 'observePluginInvariant') { - const contract = node.arguments[2] - if (contract !== undefined && ts.isObjectLiteralExpression(contract)) { - let hasExactPlugin = false - let name: string | undefined - for (const property of contract.properties) { - if (!ts.isPropertyAssignment(property)) continue - const key = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) - ? property.name.text - : undefined - if (key === 'plugin') hasExactPlugin = true - if (key === 'name') name = stringValue(property.initializer, new Map()) - } - if (!hasExactPlugin && name !== undefined) names.push(name) - } - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - return names + checkInstaller(owner, sourceFile, sourceText, violations) } function checkInstaller( owner: PackageInvariantOwner, sourceFile: ts.SourceFile, + sourceText: string, violations: PackageInvariantViolation[], ): void { let initializer: ts.Expression | undefined + let declarationStatement: ts.VariableStatement | undefined for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) continue for (const declaration of statement.declarationList.declarations) { if (ts.isIdentifier(declaration.name) && declaration.name.text === 'install' - && declaration.initializer !== undefined) initializer = declaration.initializer + && declaration.initializer !== undefined) { + initializer = declaration.initializer + declarationStatement = statement + } } } const installer = initializer === undefined ? undefined : installerFunction(initializer) @@ -257,7 +225,17 @@ function checkInstaller( return } if (ts.isBlock(installer.body) && installer.body.statements.length === 0) { - addViolation(violations, owner.sourcePath, 'install function must contain a package-owned invariant check') + const declarationText = declarationStatement === undefined + ? '' + : sourceText.slice(declarationStatement.getFullStart(), declarationStatement.getEnd()) + if (!declarationText.includes(NO_RUNTIME_INVARIANT_MARKER)) { + addViolation( + violations, + owner.sourcePath, + `empty install function must explain why with a "${NO_RUNTIME_INVARIANT_MARKER}" comment`, + ) + } + return } const reporter = installer.parameters[1]?.name if (reporter === undefined || !ts.isIdentifier(reporter)) { diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 5c3b9120bc..56837e92fb 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -72,10 +72,24 @@ describe('global test invariant host', () => { it('limits manual composition to focused invariant topology tests', () => { expect(MANUAL_INVARIANT_TESTS).toEqual([ '/packages/support/invariants/tests/service.spec.ts', + '/packages/bash/bash/tests/invariant.spec.ts', + '/packages/compact/compact/tests/invariant.spec.ts', + '/packages/context/time-context/tests/invariant.spec.ts', '/packages/core/session/tests/invariant.spec.ts', '/packages/core/agent/tests/invariant.spec.ts', '/packages/core/scope/tests/invariant.spec.ts', '/packages/core/agent-loop/tests/invariant.spec.ts', + '/packages/core/system-prompt/tests/invariant.spec.ts', + '/packages/core/tools/tests/invariant.spec.ts', + '/packages/fs/fs/tests/invariant.spec.ts', + '/packages/hooks/hook-protocol/tests/invariant.spec.ts', + '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/subagent/subagent/tests/invariant.spec.ts', + '/packages/tasks/tasks/tests/invariant.spec.ts', + '/packages/todo/tool-todo/tests/invariant.spec.ts', + '/packages/ui/permission/tests/invariant.spec.ts', + '/packages/ui/user-approval/tests/invariant.spec.ts', + '/packages/workflow/workflow/tests/invariant.spec.ts', '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', ]) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index b91fdda462..1c8c1302d6 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -31,10 +31,24 @@ export const testInvariantCompanions: Readonly to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit From b8ad62eb359409dd688ef3c2401fd6af27d2bf7f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:04:40 +0800 Subject: [PATCH 14/30] fix(invariants): finish current-master ownership migration --- docs/cordis-catalog/services.md | 2 +- packages/AGENTS.md | 4 ++-- packages/compact/compact/src/invariant.ts | 3 +++ packages/hooks/hook-protocol/src/invariant.ts | 3 +++ packages/spill/spill-policy/tests/spill-policy.spec.ts | 10 ---------- packages/support/invariants/README.md | 2 +- packages/ui/user-approval/src/invariant.ts | 3 +++ scripts/package-invariants.ts | 2 +- scripts/test-invariants.spec.ts | 2 +- scripts/test-invariants.ts | 2 +- 10 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d494c5827f..4b0e132da8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -501,7 +501,7 @@ Package-owned invariant registry with global and regex-based selection. register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:388`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:94`](../../packages/support/invariants/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 16ee490395..77a4712e04 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -15,8 +15,8 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. -- **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns an explicit invariant companion.** Publish `./invariant` and register its manifest name. Check observable event or mutable-data relationships; when none exists, keep an empty installer with a package-specific `No runtime invariant:` explanation instead of inventing an API-shape assertion. Generated companions, unexplained empties, and non-empty installers that ignore the reporter fail `verify-package-invariants` ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md)). +- **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. +- **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). Naming notes: diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index 59fdc806b2..da5d5eba5a 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -66,6 +66,8 @@ function applyCompactionTransition( } /** Install compaction start/summary/end checks. */ +// Event owners keep precommit staging local so their vocabularies never move into a central helper. +/* jscpd:ignore-start */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const traces = new WeakMap() const staged = new WeakMap() @@ -98,6 +100,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (transition !== undefined) staged.set(event, { session, transition }) }, { global: true }) }, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the compact invariant companion. diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 73d6f08e3b..6972d109da 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -57,6 +57,8 @@ function applyHookTransition(pending: Map, transition: HookTrans } /** Install hook invoked/result pairing checks. */ +// Event owners keep precommit staging local so their vocabularies never move into a central helper. +/* jscpd:ignore-start */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const traces = new WeakMap>() const staged = new WeakMap() @@ -88,6 +90,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (transition !== undefined) staged.set(event, { session, transition }) }, { global: true }) }, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the hook-protocol invariant companion. diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index f0cbbbd2a0..d249430ab1 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -110,16 +110,6 @@ describe('config validation', () => { await expect(setup({ maxInlineBytes: 1.5 })).rejects.toThrow(/non-negative integer/) }) - it('rejects a configured policy that omits its post-execute listener', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await expect(ctx.plugin({ - name: 'spill-policy', - inject: ['tools'], - apply(_child: Context, _config: { maxInlineBytes?: number }) {}, - }, { maxInlineBytes: 10 })).rejects.toThrow(/listener must exist exactly when maxInlineBytes is configured/) - }) }) describe('oversized plain-text replacement', () => { diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 58cf2e8f2a..4cd2aa1f9b 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -69,7 +69,7 @@ Every ordinary Vitest topology mounts an explicitly enabled service and the curr ## Model Experience -None. The service and companions observe runtime events, mutable snapshots, and requests but never alter prompts, messages, schemas, streams, or tool results. +None, as the service and companions observe runtime events and mutable snapshots without altering prompts, messages, schemas, streams, or tool results. #### KV Cache effect diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 9fc42fa908..5643e412c5 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -49,6 +49,8 @@ function applyApprovalTransition(pending: Set, transition: Ap } /** Install audit pairing and closed-vocabulary checks. */ +// Event owners keep precommit staging local so their vocabularies never move into a central helper. +/* jscpd:ignore-start */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const traces = new WeakMap>() const staged = new WeakMap() @@ -80,6 +82,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (transition !== undefined) staged.set(event, { session, transition }) }, { global: true }) }, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the approval invariant companion. diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 4600d04ee3..6b099c084d 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -9,7 +9,7 @@ import { dirname, relative, resolve, sep } from 'node:path' import ts from 'typescript' /** Required explanation marker for an intentionally empty installer. */ -export const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' +const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' interface PackageManifest { name?: string diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 56837e92fb..52182cafaf 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -72,7 +72,6 @@ describe('global test invariant host', () => { it('limits manual composition to focused invariant topology tests', () => { expect(MANUAL_INVARIANT_TESTS).toEqual([ '/packages/support/invariants/tests/service.spec.ts', - '/packages/bash/bash/tests/invariant.spec.ts', '/packages/compact/compact/tests/invariant.spec.ts', '/packages/context/time-context/tests/invariant.spec.ts', '/packages/core/session/tests/invariant.spec.ts', @@ -84,6 +83,7 @@ describe('global test invariant host', () => { '/packages/fs/fs/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', '/packages/subagent/subagent/tests/invariant.spec.ts', '/packages/tasks/tasks/tests/invariant.spec.ts', '/packages/todo/tool-todo/tests/invariant.spec.ts', diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 1c8c1302d6..2f5eec6271 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -31,7 +31,6 @@ export const testInvariantCompanions: Readonly Date: Mon, 20 Jul 2026 20:09:48 +0800 Subject: [PATCH 15/30] test(session): preserve surface boundary coverage --- packages/core/session/tests/surface.spec.ts | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index fc8ebfcd10..5243564b6c 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -30,6 +30,28 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { } as unknown as SessionEvent } +function toolResultEvent( + seq: number, + callId: string, + surfaceOp: SurfaceEvent['surfaceOp'] = 'append', + sourceEventSeqs?: number[], +): SessionEvent { + return { + type: 'tool/result', + seq, + time: seq, + data: { + turn: 1, + step: 1, + callId: CallId(callId), + content: [{ type: 'text', text: `result ${seq}` }], + isError: false, + }, + surfaceOp, + ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs }, + } +} + describe('foldSurface provenance', () => { it('accepts absent or valid provenance and complete replacement coverage', () => { const events = [ @@ -94,6 +116,33 @@ describe('foldSurface provenance', () => { ) }) +describe('foldSurface tool-result rewrites', () => { + it('rejects a replacement spanning multiple current nodes', () => { + const events = [ + provenanceEvent(0, undefined), + provenanceEvent(1, undefined), + toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]), + ] + expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/) + }) + + it('rejects a replacement targeting a non-result node', () => { + const events = [ + provenanceEvent(0, undefined), + toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]), + ] + expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/) + }) + + it('rejects changes outside tool-result content', () => { + const events = [ + toolResultEvent(0, 'original'), + toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]), + ] + expect(() => foldSurface(events)).toThrow(/may change only content/) + }) +}) + describe('SurfaceManager', () => { it('shares ordered entries and nested replacement ranges with foldSurface', () => { const s = new Session(SessionId('shared-fold')) From d897adac87d0adf2f38d580466c079f1329dcc64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:16:22 +0800 Subject: [PATCH 16/30] fix(invariants): keep registration gate final-head clean --- packages/AGENTS.md | 4 ++-- scripts/package-invariants.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index f1403bb973..43993b9a63 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -15,8 +15,8 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. - **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. -- **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. -- **Every package owns an invariant companion.** Publish `./invariant` and register its manifest name. Check observable event or mutable-data relations; when none exists, keep an empty installer with a `No runtime invariant:` comment explaining why instead of inventing a shape or presence assertion. `verify-package-invariants` gates source and publication wiring ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). +- **Registry contributions prove disposal** through an HMR test: dispose the contributing fiber and observe removal ([testing policy](../docs/testing.md)). +- **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give an empty installer a package-specific `No runtime invariant:` reason. `verify-package-invariants` gates the contract ([rationale](../.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md)). Naming notes: diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 0078a6a4c4..a635a4e583 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -12,7 +12,7 @@ import ts from 'typescript' export const GENERATED_INVARIANT_MARKER = '@generated scripts/gen-package-invariants.ts' /** Required explanation marker for an intentionally empty installer. */ -export const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' +const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' interface PackageManifest { name?: string From 0405a211c84a8a78b7bbded5cb180012b9952ceb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:09:48 +0800 Subject: [PATCH 17/30] test(session): preserve surface boundary coverage --- packages/core/session/tests/surface.spec.ts | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index fc8ebfcd10..5243564b6c 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -30,6 +30,28 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { } as unknown as SessionEvent } +function toolResultEvent( + seq: number, + callId: string, + surfaceOp: SurfaceEvent['surfaceOp'] = 'append', + sourceEventSeqs?: number[], +): SessionEvent { + return { + type: 'tool/result', + seq, + time: seq, + data: { + turn: 1, + step: 1, + callId: CallId(callId), + content: [{ type: 'text', text: `result ${seq}` }], + isError: false, + }, + surfaceOp, + ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs }, + } +} + describe('foldSurface provenance', () => { it('accepts absent or valid provenance and complete replacement coverage', () => { const events = [ @@ -94,6 +116,33 @@ describe('foldSurface provenance', () => { ) }) +describe('foldSurface tool-result rewrites', () => { + it('rejects a replacement spanning multiple current nodes', () => { + const events = [ + provenanceEvent(0, undefined), + provenanceEvent(1, undefined), + toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]), + ] + expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/) + }) + + it('rejects a replacement targeting a non-result node', () => { + const events = [ + provenanceEvent(0, undefined), + toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]), + ] + expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/) + }) + + it('rejects changes outside tool-result content', () => { + const events = [ + toolResultEvent(0, 'original'), + toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]), + ] + expect(() => foldSurface(events)).toThrow(/may change only content/) + }) +}) + describe('SurfaceManager', () => { it('shares ordered entries and nested replacement ranges with foldSurface', () => { const s = new Session(SessionId('shared-fold')) From c25e89cb4ce3990a2ae0bb146c45a85be51cc4f0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:23:57 +0800 Subject: [PATCH 18/30] fix(invariants): reconcile TUI package rename --- packages/examples/tui-demo/package.json | 1 + packages/ui/stdio/src/invariant.ts | 30 ------------------------- pnpm-lock.yaml | 3 +++ 3 files changed, 4 insertions(+), 30 deletions(-) delete mode 100644 packages/ui/stdio/src/invariant.ts diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index c356105964..841505fb00 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/ui/stdio/src/invariant.ts b/packages/ui/stdio/src/invariant.ts deleted file mode 100644 index 37176f11c6..0000000000 --- a/packages/ui/stdio/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Generated invariant ownership companion for `@deepseek-ai/dsh-stdio`. - * Replace this file with package-owned checks while preserving its registration. - * - * @generated scripts/gen-package-invariants.ts - * @module @deepseek-ai/dsh-stdio/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-stdio' - -/** Cordis companion plugin name. */ -export const name = 'stdio-invariant' -/** Services required before the companion can register. */ -export const inject = ['invariants'] - -/** No runtime invariant: no package-owned event or mutable-data relation has been identified yet. */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9067fc39b4..5bd0959770 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -930,6 +930,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ version: link:../../ui/tool-ask-user From e92b34bd7e42cc84ddcbe9233c30e5a58ba53e5a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:16:08 +0800 Subject: [PATCH 19/30] fix(invariants): enforce runtime relationships --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 7 +- ...-package-invariant-runtime-contracts.zh.md | 11 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 3 +- docs/testing.md | 2 - .../context/time-context/src/invariant.ts | 43 +++++- .../time-context/tests/invariant.spec.ts | 61 +++++++- packages/llm/llm-retry/README.md | 2 + packages/llm/llm-retry/package.json | 7 + packages/llm/llm-retry/src/invariant.ts | 97 ++++++++++++ .../llm/llm-retry/tests/invariant.spec.ts | 144 ++++++++++++++++++ packages/llm/llm-retry/tsconfig.json | 3 + packages/sdk/telemetry/README.md | 2 - .../sdk/telemetry/src/redaction-contract.ts | 27 ---- .../tests/redaction-contract.spec.ts | 18 --- packages/support/invariants/README.md | 4 +- pnpm-lock.yaml | 3 + scripts/package-invariants.spec.ts | 14 ++ scripts/package-invariants.ts | 15 +- scripts/test-invariants.spec.ts | 1 + scripts/test-invariants.ts | 1 + 22 files changed, 398 insertions(+), 75 deletions(-) create mode 100644 packages/llm/llm-retry/src/invariant.ts create mode 100644 packages/llm/llm-retry/tests/invariant.spec.ts delete mode 100644 packages/sdk/telemetry/src/redaction-contract.ts delete mode 100644 packages/sdk/telemetry/tests/redaction-contract.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 4750c9f9b1..4e0e5cbac0 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: c8ef723778a3dc704e781b4cfe32705e67f81b5f -2026-07-19-package-invariant-runtime-contracts.zh.md: 7df7ed1ac0743fab3302c47955221721e11fe8c7 +2026-07-19-package-invariant-runtime-contracts.md: 84c632e57b3360756660415d7bdd62397c52e4a3 +2026-07-19-package-invariant-runtime-contracts.zh.md: 51734f6922f393ae37b0c3999723b7aade7651eb diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index c8ef723778..84c632e57b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -27,7 +27,7 @@ The central `dsh-invariants` service owns only configuration, registration uniqu ### Implemented checks -The current 93-package workspace has 18 executable companions and 75 justified empty companions. +The current 94-package workspace has 19 executable companions and 75 justified empty companions. | Owner | Runtime relationship | |---|---| @@ -36,6 +36,7 @@ The current 93-package workspace has 18 executable companions and 75 justified e | `dsh-scope` | Scoped-event carrier presence and routed-subject consistency. | | `dsh-agent-loop` | Frozen loop request reconstruction from the session event log. | | `dsh-llm` | Stream block grammar, delta type/index matching, single usage, closed blocks, and terminal finish. | +| `dsh-llm-retry` | Durable retry records identify the open turn's latest closed step, remain unique per step, increase monotonically, and stay within retry and timer bounds. | | `dsh-tools` | Monotonic pre/execute/post stages and immutable final execution/result snapshots. | | `dsh-system-prompt` | Authoritative assembly section, tool, and variable data constraints. | | `dsh-compact` | Compaction start/summary/end pairing, range endpoints, token counts, and successful-summary presence. | @@ -48,13 +49,13 @@ The current 93-package workspace has 18 executable companions and 75 justified e | `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. | | `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. | | `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | -| `dsh-time-context` | Plugin-attributed clock readings agree across turn, step, elapsed baseline, rendered time, and event time. | +| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn and next pre-step position, elapsed baseline, rendered time, and event time. | Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. ### Repository gate and tests -`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter. The gate deliberately does not infer semantic quality from method names or helper calls. +`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology loads all companions to prove registration and disposal wiring. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 7df7ed1ac0..51734f6922 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -1,6 +1,6 @@ -# Agent Note:有意义的包不变量契约 +# Agent Note: 有意义的包不变量契约 -状态:已实施 +Status: implemented [English](2026-07-19-package-invariant-runtime-contracts.md) | 中文 @@ -27,7 +27,7 @@ ### 已实施的检查 -当前 93 个包的 workspace 包含 18 个可执行 companion 和 75 个有理由的空 companion。 +当前 94 个包的 workspace 包含 19 个可执行 companion 和 75 个有理由的空 companion。 | 所有者 | 运行时关系 | |---|---| @@ -36,6 +36,7 @@ | `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 | | `dsh-agent-loop` | 从 session 事件日志重建冻结的 loop 请求。 | | `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 | +| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和定时器延迟均保持在边界内。 | | `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 | | `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 | | `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 | @@ -48,13 +49,13 @@ | `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | | `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 | | `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | -| `dsh-time-context` | 标注插件来源的时钟 reading 在 turn、step、elapsed baseline、渲染时间和事件时间之间保持一致。 | +| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn 和下一个 step 开始前的位置,并在 elapsed baseline、渲染时间和事件时间之间保持一致。 | 基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 ### 仓库门禁与测试 -`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器。门禁不会通过方法名或 helper 调用推断语义质量。 +`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑加载全部 companion,以证明注册和释放 wiring。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0651237396..3b8fb21176 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,7 +28,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | @@ -54,7 +54,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`hook-protocol`](../packages/hooks/hook-protocol), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/status` | - | [`agent`](../packages/core/agent) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 388caec4d1..4460e1764c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -240,6 +240,7 @@ flowchart TD pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout @@ -596,7 +597,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | diff --git a/docs/testing.md b/docs/testing.md index 32ef0716d4..c158c9b922 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,8 +9,6 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -Every Vitest configuration mounts enabled `ctx.invariants` before ordinary roots start. Package tests add their owner's companion; one exhaustive topology mounts them all. Focused invariant topology tests compose enabled or deliberately disabled services without competing global registrations. - ## The with-key policy: inference is cheap here We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships both a keyless smoke and a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 804d419627..134d2233ee 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -18,8 +18,39 @@ export const name = 'time-context-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate one plugin-attributed time reading against its durable event timestamp. */ -function validateReading(event: SessionEvent<'context/message'>, fail: InvariantFailure): void { +/** Derive the pre-step position at which a time-context reading may append. */ +function preparationPosition(session: Session, fail: InvariantFailure): { turn: number; step: number } { + const currentTurnEvents: SessionEvent[] = [] + let openTurn: number | undefined + for (const event of session.events.slice().reverse()) { + if (event.type === 'turn/end') { + fail('time-context reading must be appended inside an open turn') + } + if (event.type === 'turn/start') { + openTurn = event.data.turn + break + } + currentTurnEvents.push(event) + } + if (openTurn === undefined) fail('time-context reading must be appended inside an open turn') + + for (const event of currentTurnEvents) { + if (event.type === 'step/start') { + fail(`time-context reading must precede step/start, but step ${event.data.step} is already open`) + } + if (event.type === 'step/end') { + return { turn: openTurn, step: event.data.step + 1 } + } + } + return { turn: openTurn, step: 1 } +} + +/** Validate one plugin-attributed time reading against its session position and timestamp. */ +function validateReading( + session: Session, + event: SessionEvent<'context/message'>, + fail: InvariantFailure, +): void { const [block] = event.data.content if (event.data.content.length !== 1 || block?.type !== 'text') { fail('time-context messages must contain exactly one text block') @@ -31,6 +62,10 @@ function validateReading(event: SessionEvent<'context/message'>, fail: Invariant if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) { fail('time-context turn and step must be positive safe integers') } + const expected = preparationPosition(session, fail) + if (turn !== expected.turn || step !== expected.step) { + fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`) + } const baseline = match[4] if ((step === 1) !== (baseline === 'model-visible message')) { fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`) @@ -49,11 +84,11 @@ function validateReading(event: SessionEvent<'context/message'>, fail: Invariant const install: InvariantInstaller = (ctx, fail) => { ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return - const event = (args as [Session, SessionEvent])[1] + const [session, event] = args as [Session, SessionEvent] if (event.type !== 'context/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return - validateReading(event, fail) + validateReading(session, event, fail) }, { global: true }) } diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index eab422e95e..b19b56c99f 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -36,12 +36,56 @@ function reading( + `Elapsed since the preceding ${baseline}: unavailable.` } +function preparing(turn: number, step: number): Session { + const session = new Session(SessionId(`time-invariant-${turn}-${step}`)) + for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) { + session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } }) + } + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + for (let priorStep = 1; priorStep < step; priorStep += 1) { + session.append('step/start', { turn, step: priorStep }) + session.append('step/end', { turn, step: priorStep }) + } + return session +} + describe('time-context invariants', () => { it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => { const ctx = await setup() const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n' + 'Elapsed since the preceding step context: 4m 2s.' - expect(() => { ctx.emit('session/event', {} as Session, event(text)) }).not.toThrow() + expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow() + }) + + it.each([ + [reading('1', '3', 'step context'), /expected turn 2\/step 3/], + [reading('2', '2', 'step context'), /expected turn 2\/step 3/], + ])('rejects a reading that disagrees with its session position', async (text, message) => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).toThrow(message) + }) + + it('rejects a reading after cancellation closes the turn', async () => { + const ctx = await setup() + const session = preparing(1, 2) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } }) + expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) + .toThrow(/inside an open turn/) + }) + + it('rejects a reading after step/start or without any open turn', async () => { + const ctx = await setup() + const started = preparing(1, 1) + started.append('step/start', { turn: 1, step: 1 }) + expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/) + expect(() => { + ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading())) + }).toThrow(/inside an open turn/) }) it.each([ @@ -61,8 +105,13 @@ describe('time-context invariants', () => { ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/], ] as const)('rejects an incoherent durable reading', async (text, time, content, message) => { const ctx = await setup() + const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1 expect(() => { - ctx.emit('session/event', {} as Session, event(text, time, content === undefined ? undefined : [...content])) + ctx.emit('session/event', preparing(1, preparationStep), event( + text, + time, + content === undefined ? undefined : [...content], + )) }).toThrow(message) }) @@ -70,11 +119,11 @@ describe('time-context invariants', () => { const ctx = await setup() const other = event('unrelated') as SessionEvent<'context/message'> other.data.source = { kind: 'plugin', plugin: 'other' } - expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() other.data.source = { kind: 'user' } - expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() expect(() => { - ctx.emit('session/event', {} as Session, { + ctx.emit('session/event', preparing(1, 1), { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, }) ctx.emit('tools/change') diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index f84fdba09a..22e4cb43e5 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -6,6 +6,8 @@ The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, an Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. +The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and timer delay. + ```yaml - name: '@deepseek-ai/dsh-llm-retry' config: diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index fce40dbe33..6d6c27636c 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -11,10 +11,15 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -22,6 +27,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", @@ -35,6 +41,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts new file mode 100644 index 0000000000..b925e9148d --- /dev/null +++ b/packages/llm/llm-retry/src/invariant.ts @@ -0,0 +1,97 @@ +/** Package-owned durable retry-event invariants. @module @deepseek-ai/dsh-llm-retry/invariant */ + +import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type {} from './index.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry' + +/** Cordis companion plugin name. */ +export const name = 'llm-retry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Validate one retry record against the open turn and most recently closed step. */ +function validateRetry( + history: readonly SessionEvent[], + event: SessionEvent<'llm/retry'>, + fail: InvariantFailure, +): void { + const { turn, step, retry, maxRetries, delayMs } = event.data + if (!Number.isSafeInteger(retry) || retry < 1) { + fail('llm/retry retry must be a positive safe integer') + } + if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { + fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) + } + if (!(delayMs > 0 && delayMs <= MAX_TIMER_DELAY_MS)) { + fail(`llm/retry delayMs must be within 1..${MAX_TIMER_DELAY_MS}`) + } + + const currentTurnEvents: SessionEvent[] = [] + let openTurn: number | undefined + for (const prior of history.slice().reverse()) { + if (prior.type === 'turn/end') fail('llm/retry must be appended inside an open turn') + if (prior.type === 'turn/start') { + openTurn = prior.data.turn + break + } + currentTurnEvents.push(prior) + } + if (openTurn === undefined) fail('llm/retry must be appended inside an open turn') + if (turn !== openTurn) { + fail(`llm/retry names turn ${turn}, but the open turn is ${openTurn}`) + } + + let closedStep: number | undefined + for (const prior of currentTurnEvents) { + if (prior.type === 'step/start') { + fail(`llm/retry must follow step/end, but step ${prior.data.step} is still open`) + } + if (prior.type === 'step/end') { + closedStep = prior.data.step + break + } + } + if (closedStep === undefined || step !== closedStep) { + fail(`llm/retry names step ${step}, but the latest closed step is ${String(closedStep)}`) + } + + const priorRetries = currentTurnEvents + .filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry') + if (priorRetries.some(prior => prior.data.step === step)) { + fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`) + } + const priorRetry = priorRetries[0] + if (priorRetry !== undefined && retry <= priorRetry.data.retry) { + fail(`llm/retry retry ${retry} must increase after retry ${priorRetry.data.retry}`) + } +} + +/** Validate every retry record already present in one loaded session. */ +function validateSession(session: Session, fail: InvariantFailure): void { + for (const [index, event] of session.events.entries()) { + if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail) + } +} + +/** Install validation for loaded and newly appended retry records. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) validateSession(session, fail) + ctx.on('session/created', (session) => { validateSession(session, fail) }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (event.type === 'llm/retry') validateRetry(session.events, event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** + * Register the LLM retry invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts new file mode 100644 index 0000000000..4ffffd4c4b --- /dev/null +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(RetryInvariant) + return ctx +} + +function closeStep(ctx: Context, id: string, turn = 1, step = 1) { + const session = ctx.sessions.create(SessionId(id)) + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn, step }) + session.append('step/end', { turn, step }) + return session +} + +const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } + +describe('llm-retry invariants', () => { + it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-valid') + expect(() => { + session.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 500, failure, + }) + session.append('step/start', { turn: 1, step: 2 }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('llm/retry', { + turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure, + }) + }).not.toThrow() + expect(() => { ctx.emit('tools/change') }).not.toThrow() + }) + + it.each([ + [{ retry: 0, maxRetries: 2, delayMs: 1 }, /positive safe integer/], + [{ retry: 1.5, maxRetries: 2, delayMs: 1 }, /positive safe integer/], + [{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/], + [{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/], + [{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/], + [{ retry: 1, maxRetries: 2, delayMs: 0 }, /delayMs/], + [{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/], + ])('rejects invalid retry bounds %#', async (data, message) => { + const ctx = await setup() + const session = closeStep(ctx, `retry-invariant-bounds-${data.retry}-${data.maxRetries}-${data.delayMs}`) + expect(() => { + session.append('llm/retry', { turn: 1, step: 1, ...data, failure }) + }).toThrow(message) + }) + + it('rejects retry records outside the matching closed-step boundary', async () => { + const ctx = await setup() + const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn')) + expect(() => { + absent.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/inside an open turn/) + + const wrongTurn = closeStep(ctx, 'retry-invariant-wrong-turn') + expect(() => { + wrongTurn.append('llm/retry', { + turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/open turn is 1/) + + const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step')) + openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + openStep.append('step/start', { turn: 1, step: 1 }) + expect(() => { + openStep.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/step 1 is still open/) + + const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step')) + noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => { + noStep.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/latest closed step is undefined/) + + const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step') + expect(() => { + wrongStep.append('llm/retry', { + turn: 1, step: 2, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/latest closed step is 1/) + + const closedTurn = closeStep(ctx, 'retry-invariant-closed-turn') + closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } }) + expect(() => { + closedTurn.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/inside an open turn/) + }) + + it('rejects duplicate and non-increasing retry records', async () => { + const ctx = await setup() + const duplicate = closeStep(ctx, 'retry-invariant-duplicate') + duplicate.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + }) + expect(() => { + duplicate.append('llm/retry', { + turn: 1, step: 1, retry: 2, maxRetries: 3, delayMs: 1, failure, + }) + }).toThrow(/duplicates the retry record/) + + const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing') + nonIncreasing.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + }) + nonIncreasing.append('step/start', { turn: 1, step: 2 }) + nonIncreasing.append('step/end', { turn: 1, step: 2 }) + expect(() => { + nonIncreasing.append('llm/retry', { + turn: 1, step: 2, retry: 1, maxRetries: 3, delayMs: 1, failure, + }) + }).toThrow(/must increase/) + }) + + it('validates existing histories on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('retry-invariant-late')) + session.append('step/end', { turn: 1, step: 1 }) + session.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) + }) +}) diff --git a/packages/llm/llm-retry/tsconfig.json b/packages/llm/llm-retry/tsconfig.json index 44310af6e9..48c858951a 100644 --- a/packages/llm/llm-retry/tsconfig.json +++ b/packages/llm/llm-retry/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/agent" }, + { + "path": "../../support/invariants" + }, { "path": "../../util/timeout" } diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 4ff1cf1387..01a2ee6c2d 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -10,8 +10,6 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li | `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | -The separately published `./invariant` diagnostic companion samples the security-critical redaction boundary: credential values must disappear, ordinary package metadata must survive, and a second redaction pass must be idempotent. - Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. diff --git a/packages/sdk/telemetry/src/redaction-contract.ts b/packages/sdk/telemetry/src/redaction-contract.ts deleted file mode 100644 index c18a582308..0000000000 --- a/packages/sdk/telemetry/src/redaction-contract.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Structural redactor surface sampled by the telemetry invariant. */ -export interface TelemetryRedactionBoundary { - /** Redact credential material in free-form telemetry content. */ - redactText(value: string): string -} - -/** - * Validate the security-critical telemetry redaction boundary. - * @param redactor - candidate content redactor. - * @param placeholder - expected replacement for a secret value. - * @param packageName - ordinary package metadata that must survive redaction. - * @returns the violated contract, or `undefined` when redaction is safe and idempotent. - */ -export function telemetryRedactionViolation( - redactor: TelemetryRedactionBoundary, - placeholder: string, - packageName: string, -): string | undefined { - const secret = 'sk-abcdefghij1234567890' - const redacted = redactor.redactText(`apiKey: ${secret}\nname: ${packageName}\n`) - return !redacted.includes(secret) - && redacted.includes(`apiKey: ${placeholder}`) - && redacted.includes(`name: ${packageName}`) - && redactor.redactText(redacted) === redacted - ? undefined - : 'telemetry redaction must remove credential values, preserve package metadata, and remain idempotent' -} diff --git a/packages/sdk/telemetry/tests/redaction-contract.spec.ts b/packages/sdk/telemetry/tests/redaction-contract.spec.ts deleted file mode 100644 index 0fcc05e5b4..0000000000 --- a/packages/sdk/telemetry/tests/redaction-contract.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SecretRedactor } from '../src/secret-redactor.ts' -import { telemetryRedactionViolation } from '../src/redaction-contract.ts' - -describe('telemetryRedactionViolation', () => { - it('accepts the shipped redactor and rejects one that preserves credentials', () => { - expect(telemetryRedactionViolation( - new SecretRedactor(), - '[REDACTED]', - '@deepseek-ai/dsh-telemetry', - )).toBeUndefined() - expect(telemetryRedactionViolation( - { redactText: value => value }, - '[REDACTED]', - '@deepseek-ai/dsh-telemetry', - )).toBe('telemetry redaction must remove credential values, preserve package metadata, and remain idempotent') - }) -}) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 4cd2aa1f9b..f05855274f 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -35,12 +35,12 @@ The current executable companions protect these relationships: | Companion | Checks | |---|---| | `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. | -| `dsh-llm`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | +| `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | | `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. | | `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | | `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. | | `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. | -| `dsh-time-context` | Durable clock readings agree with their turn, step, elapsed baseline, and event timestamp. | +| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position, elapsed baseline, and event timestamp. | The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no product checks, and loading a companion without the service waits on its declared `invariants` injection. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 573c95a2a4..c616cdf9a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,6 +1351,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 1014ce5033..54d777ac9a 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -138,6 +138,20 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo .toContain('install function must use its bound failure reporter') }) + it('rejects registering a different installer than the checked local function', () => { + const decoy = fixture({ + source: ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = (_ctx: unknown, fail: (message: string) => never) => { fail('checked decoy') } +export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => + ctx.invariants.register('@deepseek-ai/dsh-probe', () => {}) +`, + }) + expect(collectPackageInvariantViolations(decoy).map(violation => violation.message)) + .toContain('line 6: ctx.invariants.register must use the checked local install function') + }) + it('accepts explained empty installers and rejects unexplained ones', () => { const explained = ` export const name = 'probe-invariant' diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 6b099c084d..931f6ff588 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -167,12 +167,18 @@ function checkSource( const constants = topLevelStringConstants(sourceFile) const registrations: string[] = [] const unresolved: number[] = [] + const mismatchedInstallers: number[] = [] const visit = (node: ts.Node): void => { if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) { + const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1 const argument = node.arguments[0] const packageName = argument === undefined ? undefined : stringValue(argument, constants) - if (packageName === undefined) unresolved.push(sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1) + if (packageName === undefined) unresolved.push(line) else registrations.push(packageName) + const installer = node.arguments[1] + if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') { + mismatchedInstallers.push(line) + } } ts.forEachChild(node, visit) } @@ -185,6 +191,13 @@ function checkSource( `line ${line}: ctx.invariants.register package name must resolve to a local string constant`, ) } + for (const line of mismatchedInstallers) { + addViolation( + violations, + owner.sourcePath, + `line ${line}: ctx.invariants.register must use the checked local install function`, + ) + } if (registrations.length !== 1 || registrations[0] !== owner.packageName) { addViolation( violations, diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 52182cafaf..c16c9402b5 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -83,6 +83,7 @@ describe('global test invariant host', () => { '/packages/fs/fs/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/llm/llm-retry/tests/invariant.spec.ts', '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', '/packages/subagent/subagent/tests/invariant.spec.ts', '/packages/tasks/tasks/tests/invariant.spec.ts', diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 2f5eec6271..1f218e16f9 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -42,6 +42,7 @@ export const MANUAL_INVARIANT_TESTS = [ '/packages/fs/fs/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/llm/llm-retry/tests/invariant.spec.ts', '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', '/packages/subagent/subagent/tests/invariant.spec.ts', '/packages/tasks/tasks/tests/invariant.spec.ts', From a4be4b5e5264e1016cec414d7d43c03c0e15c3d6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:29:22 +0800 Subject: [PATCH 20/30] docs(invariants): codify semantic review rules --- .agents/skills/dsh-code-review/SKILL.md | 5 +++-- AGENTS.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 814132d481..925dc5b6ac 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -23,7 +23,8 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 2. **Docs match the code.** Config, defaults, errors, wire fields, events, and public behavior update the package README and JSDoc in the same diff. Comments state non-obvious contracts; flag implementation narration, test walkthroughs, review history, and duplicated rationale for deletion or a link to their one home. 3. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [core-data-structures](../../../docs/core-data-structures/core.md) page and any `type-equiv` entry. Internal types need no catalog entry. 4. **Registrations clean up.** Verify each new registry contribution satisfies the disposal-test contract in [packages/AGENTS.md](../../../packages/AGENTS.md). -5. **Required gates pass.** Trust the [current readiness sequence](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready) and `pnpm run check:pre-push` for their enforced inventory; review the semantic gaps they cannot detect. +5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at its authoritative boundary; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package contract](../../../packages/AGENTS.md)). +6. **Required gates pass.** Trust the [current readiness sequence](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready) and `pnpm run check:pre-push` for their enforced inventory; review the semantic gaps they cannot detect. ## Manual checks @@ -38,7 +39,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - **Bounds cover the final operation:** locate the owner of the complete emitted or retained result, including wrappers and metadata. Probe tiny and exact limits, oversized single chunks, and multibyte text for byte limits. - **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch Loader export-shape failures; a function plugin must named-export its namespace and have no default export. - **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. Coverage is necessary but not evidence that the scenario is correct. -- **Mechanized invariants and negative controls:** trace each new or changed check through the executed top-level gate and its deliberately invalid case; confirm the real runner fails for the intended rule. +- **Invariant lifecycle and negative controls:** verify candidate observations are rejected before publication where possible, session-backed checks reconstruct durable history after late loading or HMR, and a deliberately invalid case fails through the real runner for the intended rule. - **Implemented Agent Notes match shipped reality:** when a PR implements a proposed Agent Note, move and rewrite it as present-tense shipped state in the same diff, then verify paths, names, and mechanisms against the implementation. - **Transcript changes:** editor-visible or model-visible changes update snapshots or explain why no snapshot applies. Review expected-output diffs as behavior changes, not formatting noise. - **Bilingual changes:** compare meaning and terminology on both sides; a green pairing hash does not prove translation quality. diff --git a/AGENTS.md b/AGENTS.md index 860f60f1e0..fc39c69df3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. - ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)). - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. +- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). From 0c08e34a4ab2a7eaa399485fadfb385fd2c6c2f7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:25:38 +0800 Subject: [PATCH 21/30] fix(invariants): harden runtime contracts and gates --- .../2026-07-05-reconstructable-requests.md | 4 +- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 10 ++-- ...-package-invariant-runtime-contracts.zh.md | 10 ++-- package.json | 3 +- packages/context/time-context/README.md | 2 + .../context/time-context/src/invariant.ts | 4 +- .../time-context/tests/invariant.spec.ts | 14 +++-- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/invariant.ts | 7 ++- packages/core/agent-loop/src/loop.ts | 5 +- .../core/agent-loop/src/request-marker.ts | 22 ++++++++ .../core/agent-loop/tests/invariant.spec.ts | 47 +++++++++++++---- .../agent-spine-demo/tests/agent-core.spec.ts | 2 +- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/src/invariant.ts | 4 +- .../llm/llm-retry/tests/invariant.spec.ts | 6 ++- packages/llm/llm-retry/tests/retry.spec.ts | 23 +++++++++ packages/support/invariants/README.md | 4 +- packages/support/invariants/src/index.ts | 33 ++++++------ .../support/invariants/tests/service.spec.ts | 22 ++++++++ scripts/package-invariants.spec.ts | 9 ++++ scripts/package-invariants.ts | 16 ++++++ scripts/run-gates.ts | 10 ++++ scripts/test-invariants.spec.ts | 44 +++++++--------- scripts/test-invariants.ts | 39 +++++--------- scripts/verify-built-package-invariants.mjs | 51 +++++++++++++++++++ 27 files changed, 288 insertions(+), 111 deletions(-) create mode 100644 packages/core/agent-loop/src/request-marker.ts create mode 100644 scripts/verify-built-package-invariants.mjs diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 1303a77c42..40cd4180e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -12,7 +12,7 @@ The reference shape for the happy path is MiniCode's `LLMClient`: a stateful con ### The principle -**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant by the unfrozen-request marker. +**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant because only the loop marks request ownership. Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3. @@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session **`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. -**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. +**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. The loop applies an internal non-enumerable identity before freezing each request; the independently built companion recognizes that identity, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 4e0e5cbac0..de5e18bb20 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: 84c632e57b3360756660415d7bdd62397c52e4a3 -2026-07-19-package-invariant-runtime-contracts.zh.md: 51734f6922f393ae37b0c3999723b7aade7651eb +2026-07-19-package-invariant-runtime-contracts.md: ab4301a80b01ac2b4b2870f02c232ab321a6fab1 +2026-07-19-package-invariant-runtime-contracts.zh.md: 2e0816b2f381fd8fa51df51d8c482d6d4adf48b4 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index 84c632e57b..ab4301a80b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -34,9 +34,9 @@ The current 94-package workspace has 19 executable companions and 75 justified e | `dsh-session` | Strict sequence growth, turn/step enclosure, and same-step tool call/result pairing. | | `dsh-agent` | Non-repeating agent status and terminal disposal transitions. | | `dsh-scope` | Scoped-event carrier presence and routed-subject consistency. | -| `dsh-agent-loop` | Frozen loop request reconstruction from the session event log. | +| `dsh-agent-loop` | Explicitly marked, frozen loop request reconstruction from the session event log. | | `dsh-llm` | Stream block grammar, delta type/index matching, single usage, closed blocks, and terminal finish. | -| `dsh-llm-retry` | Durable retry records identify the open turn's latest closed step, remain unique per step, increase monotonically, and stay within retry and timer bounds. | +| `dsh-llm-retry` | Durable retry records identify the open turn's latest closed step, remain unique per step, increase monotonically, and stay within retry and non-negative timer bounds. | | `dsh-tools` | Monotonic pre/execute/post stages and immutable final execution/result snapshots. | | `dsh-system-prompt` | Authoritative assembly section, tool, and variable data constraints. | | `dsh-compact` | Compaction start/summary/end pairing, range endpoints, token counts, and successful-summary presence. | @@ -49,15 +49,15 @@ The current 94-package workspace has 19 executable companions and 75 justified e | `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. | | `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. | | `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | -| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn and next pre-step position, elapsed baseline, rendered time, and event time. | +| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn, next pre-step position, and elapsed baseline; rendered time parses and does not postdate its event. | Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. ### Repository gate and tests -`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. +`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. -Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology loads all companions to prove registration and disposal wiring. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. +Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate imports every compiled `./invariant` self-reference under plain Node and repeats that Loader-shape check. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 51734f6922..2e0816b2f3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -34,9 +34,9 @@ Status: implemented | `dsh-session` | 序号严格递增、turn/step 包围关系,以及同一 step 内的工具调用/结果配对。 | | `dsh-agent` | agent 状态不得重复,并且不能离开终态 disposed。 | | `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 | -| `dsh-agent-loop` | 从 session 事件日志重建冻结的 loop 请求。 | +| `dsh-agent-loop` | 从 session 事件日志重建带显式标记的冻结 loop 请求。 | | `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 | -| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和定时器延迟均保持在边界内。 | +| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和非负的定时器延迟均保持在边界内。 | | `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 | | `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 | | `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 | @@ -49,15 +49,15 @@ Status: implemented | `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | | `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 | | `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | -| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn 和下一个 step 开始前的位置,并在 elapsed baseline、渲染时间和事件时间之间保持一致。 | +| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn、下一个 step 开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 | 基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 ### 仓库门禁与测试 -`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 +`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 -Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑加载全部 companion,以证明注册和释放 wiring。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 +Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁在 plain Node 下导入每个已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 ## 考虑过的替代方案 diff --git a/package.json b/package.json index 00a9da4d3d..5f1441c115 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", + "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-agent-note-classification": "tsx scripts/verify-agent-note-classification.ts", @@ -78,7 +79,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-agent-note-classification && pnpm run verify-agent-note-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run docs:check", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/tui-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index dc79fb6d23..2327da81b2 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -26,6 +26,8 @@ Step 1 measures from the latest preceding model-visible message, including the p A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback. +The separately published `./invariant` companion checks each plugin-attributed reading against the open turn, next pre-step position, elapsed baseline, and durable event time. Its rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading. + The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one. ## Model Experience diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 134d2233ee..c3d291c7af 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -75,8 +75,8 @@ function validateReading( if (rendered === undefined) fail('time-context reading omitted its rendered timestamp') const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, '')) if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time) - || event.time < renderedTime || event.time - renderedTime >= 1_000) { - fail('time-context rendered timestamp must identify the durable event second') + || event.time < renderedTime) { + fail('time-context rendered timestamp must parse and not postdate its durable event') } } diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index b19b56c99f..4bcd0f6774 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -62,6 +62,13 @@ describe('time-context invariants', () => { expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow() }) + it('accepts a reading durably appended after a long process pause', async () => { + const ctx = await setup() + expect(() => { + ctx.emit('session/event', preparing(1, 1), event(reading(), SECOND + 60_000)) + }).not.toThrow() + }) + it.each([ [reading('1', '3', 'step context'), /expected turn 2\/step 3/], [reading('2', '2', 'step context'), /expected turn 2\/step 3/], @@ -96,10 +103,9 @@ describe('time-context invariants', () => { [reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/], [reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/], [reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/], - [reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /durable event second/], - [reading(), Number.NaN, undefined, /durable event second/], - [reading(), SECOND - 1, undefined, /durable event second/], - [reading(), SECOND + 1_000, undefined, /durable event second/], + [reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /must parse and not postdate/], + [reading(), Number.NaN, undefined, /must parse and not postdate/], + [reading(), SECOND - 1, undefined, /must parse and not postdate/], ['ignored', SECOND, [], /exactly one text block/], ['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/], ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/], diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 72558fd740..e2bdd34d03 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -29,7 +29,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo ### Invariant companion -The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. For each frozen loop-built request carrying a live session id, it independently rebuilds the message boundary and folded request header from the session log; direct one-shot calls remain outside this marker contract. +The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. The loop marks each request with an internal non-enumerable identity before freezing it; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id. ### Configuration (schemastery) diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index ab6a885dc0..17e55af6ca 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -7,6 +7,7 @@ import type { Context } from 'cordis' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' +import { isLoopRequest } from './request-marker.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop' @@ -20,9 +21,11 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant // Prepend prevents a short-circuiting replay listener from silencing the // check; correctness itself comes from the sequence-bounded reconstruction. ctx.on('llm/stream', (options: GenerateOptions, next) => { - if (options.sessionId === undefined || !Object.isFrozen(options)) return next() + if (!isLoopRequest(options)) return next() + if (!Object.isFrozen(options)) fail('a loop-built request must be frozen') + if (options.sessionId === undefined) fail('a loop-built request must carry a session id') const session = ctx.sessions.get(options.sessionId) - if (!session) return next() + if (!session) fail(`a loop-built request must carry a live session id, got "${String(options.sessionId)}"`) if (!Object.isFrozen(options.messages)) { fail('a loop-built request must carry a frozen messages array') } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 97a32e2f38..c14e126566 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -15,6 +15,7 @@ import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' import type { TransmissionLog } from './request-log.ts' +import { markLoopRequest } from './request-marker.ts' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' @@ -619,7 +620,7 @@ async function runStep( recordRequestHeader(session, transmission, header) // Freeze the logged header plus boundary snapshot; the prefix precedes derived history. - const request: GenerateOptions = deepFreeze({ + const request: GenerateOptions = deepFreeze(markLoopRequest({ provider: header.config.provider, model: header.config.model, messages: [...header.messagePrefix ?? [], ...boundaryMessages], @@ -630,7 +631,7 @@ async function runStep( ...header.config.stop !== undefined ? { stop: header.config.stop } : {}, sessionId: session.id, signal, - }) + })) // --- Model call (streaming-first; raw chunks are the replay record) --- const assembler = new BlockAssembler() diff --git a/packages/core/agent-loop/src/request-marker.ts b/packages/core/agent-loop/src/request-marker.ts new file mode 100644 index 0000000000..ac8c750ea1 --- /dev/null +++ b/packages/core/agent-loop/src/request-marker.ts @@ -0,0 +1,22 @@ +/** Internal identity shared by the independently bundled loop and invariant companion. */ + +const LOOP_REQUEST = Symbol.for('@deepseek-ai/dsh-agent-loop/request') + +/** + * Mark a request as owned by the agent loop before it is frozen. + * @param request - mutable request object being assembled by the loop. + * @returns the same request with a non-enumerable loop identity. + */ +export function markLoopRequest(request: T): T { + Object.defineProperty(request, LOOP_REQUEST, { value: true }) + return request +} + +/** + * Test whether a request carries the agent loop's internal identity. + * @param request - request observed at the LLM stream boundary. + * @returns whether the loop marked this exact request object. + */ +export function isLoopRequest(request: object): boolean { + return Reflect.get(request, LOOP_REQUEST) === true +} diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index a61cda6f99..c5030bf161 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import InvariantService from '@deepseek-ai/dsh-invariants' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' +import { markLoopRequest } from '../src/request-marker.ts' async function setup(): Promise { const ctx = new Context() @@ -16,6 +17,10 @@ function dispatch(ctx: Context, options: unknown): void { void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never) } +function loopRequest(options: T): Readonly { + return Object.freeze(markLoopRequest(options)) +} + async function requestSetup() { const ctx = await setup() const session = ctx.sessions.create(SessionId('req-check')) @@ -30,14 +35,14 @@ async function requestSetup() { describe('request-reconstruction invariant', () => { it('accepts a frozen request equal to the boundary derivation and folded header', async () => { const { ctx, session, boundary } = await requestSetup() - const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) + const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) expect(() => { dispatch(ctx, options) }).not.toThrow() }) it('uses the step boundary rather than content appended afterward', async () => { const { ctx, session, boundary } = await requestSetup() session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) - const options = Object.freeze({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) + const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) expect(() => { dispatch(ctx, options) }).not.toThrow() }) @@ -45,20 +50,20 @@ describe('request-reconstruction invariant', () => { const { ctx, session, boundary } = await requestSetup() const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'catalog' }] } session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' }) - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) }) .not.toThrow() - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) }) it('rejects message and header divergence', async () => { const { ctx, session, boundary } = await requestSetup() const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }] - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) }) .toThrow(/diverges from the boundary derivation/) - expect(() => { dispatch(ctx, Object.freeze({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) }) .toThrow(/diverges from the folded request header/) }) @@ -66,7 +71,7 @@ describe('request-reconstruction invariant', () => { 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 }) + const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id }) expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/) session.append('step/start', { turn: 1, step: 1 }) expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/) @@ -74,12 +79,34 @@ describe('request-reconstruction invariant', () => { it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => { const { ctx, session, boundary } = await requestSetup() - expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: [...boundary], sessionId: session.id })) }) + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: [...boundary], sessionId: session.id })) }) .toThrow(/frozen messages array/) expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow() expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow() expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) }) .not.toThrow() + + const directSession = ctx.sessions.create(SessionId('direct-one-shot')) + expect(() => { + dispatch(ctx, Object.freeze({ model: 'one-shot', messages: Object.freeze([]), sessionId: directSession.id })) + }).not.toThrow() + }) + + it('rejects malformed requests carrying the loop marker', async () => { + const { ctx, session } = await requestSetup() + expect(() => { + dispatch(ctx, markLoopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })) + }).toThrow(/request must be frozen/) + expect(() => { + dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) })) + }).toThrow(/carry a session id/) + expect(() => { + dispatch(ctx, loopRequest({ + model: 'm', + messages: Object.freeze([]), + sessionId: SessionId('missing-loop-session'), + })) + }).toThrow(/live session id/) }) it('prepends ahead of a short-circuiting stream listener', async () => { @@ -93,7 +120,7 @@ describe('request-reconstruction invariant', () => { session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' }) - const divergent = Object.freeze({ + const divergent = loopRequest({ model: 'm', messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]), sessionId: session.id, diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 9c4fc916a0..1e4fee8b97 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -521,7 +521,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(typeof unwrapped.apply).toBe('function') }) - it('keeps every invariant companion loadable through the real Loader unwrap path', () => { + it('keeps each standard-spine invariant companion loadable through the real Loader unwrap path', () => { const loader = Object.create(Loader.prototype) as Loader for (const companion of [sessionInvariant, agentInvariant, scopeInvariant, agentLoopInvariant]) { expect('default' in companion).toBe(false) diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 22e4cb43e5..699e7e3dad 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -6,7 +6,7 @@ The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, an Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. -The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and timer delay. +The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. ```yaml - name: '@deepseek-ai/dsh-llm-retry' diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index b925e9148d..784f459606 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -26,8 +26,8 @@ function validateRetry( if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) } - if (!(delayMs > 0 && delayMs <= MAX_TIMER_DELAY_MS)) { - fail(`llm/retry delayMs must be within 1..${MAX_TIMER_DELAY_MS}`) + if (!(delayMs >= 0 && delayMs <= MAX_TIMER_DELAY_MS)) { + fail(`llm/retry delayMs must be within 0..${MAX_TIMER_DELAY_MS}`) } const currentTurnEvents: SessionEvent[] = [] diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 4ffffd4c4b..81b1aca16d 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -36,6 +36,10 @@ describe('llm-retry invariants', () => { session.append('llm/retry', { turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure, }) + const zeroDelay = closeStep(ctx, 'retry-invariant-zero-delay') + zeroDelay.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 1, delayMs: 0, failure, + }) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() }) @@ -46,7 +50,7 @@ describe('llm-retry invariants', () => { [{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/], [{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/], [{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/], - [{ retry: 1, maxRetries: 2, delayMs: 0 }, /delayMs/], + [{ retry: 1, maxRetries: 2, delayMs: -1 }, /delayMs/], [{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/], ])('rejects invalid retry bounds %#', async (data, message) => { const ctx = await setup() diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 8e2f086e97..bf6871668d 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -232,6 +232,29 @@ describe('bounded transient retry policy', () => { }) }) + it('accepts the zero-delay lower jitter bound', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('busy', 'SERVER'), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(adapter, { + initialDelayMs: 1, + maxDelayMs: 1, + jitterRatio: 1, + }, undefined, { random: () => 0 })) + const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'go' }]) + expect((await scheduled).data.delayMs).toBe(0) + + const idle = waitForIdle(context, agent) + await vi.runAllTimersAsync() + await idle + expect(adapter.requests).toHaveLength(2) + }) + it('uses a bounded provider Retry-After verbatim and delegates an over-cap instruction', async () => { vi.useFakeTimers() const accepted = new ScriptedAdapter([ diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index f05855274f..a650558cd7 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -40,7 +40,7 @@ The current executable companions protect these relationships: | `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | | `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. | | `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. | -| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position, elapsed baseline, and event timestamp. | +| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position and elapsed baseline; rendered time parses and does not postdate its event. | The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no product checks, and loading a companion without the service waits on its declared `invariants` injection. @@ -77,6 +77,6 @@ None; invariant checks do not assemble or send provider requests. ## Known Limitations and Deferred Work -- Request reconstruction covers frozen loop-built requests with a live session id; direct one-shot LLM calls remain outside that marker contract. +- Request reconstruction covers requests explicitly marked by the loop before freezing; direct one-shot LLM calls remain outside that marker contract even when callers freeze them or attach a session id. - Live-only lifecycle companions cannot reconstruct operations that began before their own reload. Standard and test compositions mount them before the corresponding operations begin. - Regular-expression filters are fixed for the service lifetime; changing them requires ordinary Cordis plugin reload. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 43216baa1e..2e6194b59b 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -162,27 +162,28 @@ export class InvariantService extends Service { throw new InvariantError(packageName, message) }) ) - const child = ctx.plugin(installer.inject === undefined - ? installInvariant - : Object.assign(installInvariant, { inject: installer.inject })) - try { - await child - } catch (error) { - try { - await child.dispose() - } finally { - registrations.delete(packageName) - } - throw error - } + const child = ctx.plugin(installer.inject === undefined + ? installInvariant + : Object.assign(installInvariant, { inject: installer.inject })) - return async () => { try { + await child + } catch (error) { await child.dispose() - } finally { - registrations.delete(packageName) + throw error } + + return async () => { + try { + await child.dispose() + } finally { + registrations.delete(packageName) + } + } + } catch (error) { + registrations.delete(packageName) + throw error } }, `invariants.register(${JSON.stringify(packageName)})`) } catch (error) { diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index b190379302..9000fb8955 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -260,6 +260,28 @@ describe('InvariantService lifecycle', () => { expect(retry).toHaveBeenCalledOnce() }) + it('rolls back publication effects and ownership when child-fiber publication fails', async () => { + const { ctx } = await setup() + const leaked = vi.fn() + let rejectPublication = true + const stopRejecting = ctx.on('internal/plugin', (fiber) => { + if (!rejectPublication || fiber.uid === null) return + rejectPublication = false + fiber.ctx.on('invariants-test/ping', leaked, { global: true }) + throw new Error('publication failed') + }) + + const failed = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-publication-probe', () => {})) + await expect(Promise.resolve(failed)).rejects.toThrow('publication failed') + ctx.emit('invariants-test/ping') + expect(leaked).not.toHaveBeenCalled() + stopRejecting() + + const retry = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-publication-probe', () => {})) + await retry + await retry() + }) + it('joins asynchronous checks and rolls back their effects on failure', async () => { const { ctx } = await setup() const leaked = vi.fn() diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 54d777ac9a..787202c9d1 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -152,6 +152,15 @@ export const apply = (ctx: { invariants: { register(name: string, install: () => .toContain('line 6: ctx.invariants.register must use the checked local install function') }) + it.each([ + 'export default { name, inject, apply }', + "export * as default from './probe.ts'", + ])('rejects a default export that would collapse the Loader namespace', (defaultExport) => { + const source = `${handwrittenInvariant('@deepseek-ai/dsh-probe')}\n${defaultExport}\n` + expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message)) + .toContain('must not default-export; Loader must retain the companion namespace') + }) + it('accepts explained empty installers and rejects unexplained ones', () => { const explained = ` export const name = 'probe-invariant' diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 931f6ff588..21bc5931ec 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -210,6 +210,9 @@ function checkSource( addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`) } } + if (hasDefaultExport(sourceFile)) { + addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace') + } checkInstaller(owner, sourceFile, sourceText, violations) } @@ -314,6 +317,19 @@ function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean { }) } +function hasDefaultExport(sourceFile: ts.SourceFile): boolean { + return sourceFile.statements.some((statement) => { + if (ts.isExportAssignment(statement)) return true + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined + if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true + if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false + if (ts.isNamespaceExport(statement.exportClause)) { + return statement.exportClause.name.text === 'default' + } + return statement.exportClause.elements.some(element => element.name.text === 'default') + }) +} + /** Format violations for the command-line gate. */ export function formatPackageInvariantViolation( root: string, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b51e47cedc..582972f778 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -224,6 +224,7 @@ function ciPrimaryGates(): Gate[] { label: 'node-next types', needs: ['build'], }), + builtPackageInvariantsGate(['build']), builtBinSmokeGate(), ] } @@ -248,6 +249,7 @@ function ciArtifactGates(): Gate[] { label: 'node-next types', needs: ['build'], }), + builtPackageInvariantsGate(['build']), builtBinSmokeGate(), ] } @@ -295,6 +297,13 @@ function snapshotGate(): Gate { }) } +function builtPackageInvariantsGate(needs?: string[]): Gate { + return pnpmScript('built-package-invariants', 'verify-built-package-invariants', { + label: 'built package invariants', + ...needs === undefined ? {} : { needs }, + }) +} + function positiveIntArg(envName: string, flag: string): string[] { const raw = process.env[envName] if (raw === undefined || raw === '') return [] @@ -312,6 +321,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { pnpmScript('publint', 'publint', artifactOptions), pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), + builtPackageInvariantsGate(options.artifactNeeds), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', ...artifactOptions, diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index c16c9402b5..7a4f678be8 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' +import Loader from '@cordisjs/plugin-loader' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' import { - MANUAL_INVARIANT_TESTS, testInvariantCompanionPaths, testInvariantCompanions, + usesManualInvariantTree, } from './test-invariants.ts' declare module 'cordis' { @@ -51,9 +52,10 @@ describe('global test invariant host', () => { .toEqual(Object.keys(testInvariantCompanions).sort()) }) - it('executes each companion registration with its owning package name', async () => { + it('loads and executes every source companion through the real Loader shape', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map() + const loader = Object.create(Loader.prototype) as Loader const register = vi.fn((_packageName: string, installer: InvariantInstaller) => { expect(typeof installer).toBe('function') return () => {} @@ -61,7 +63,13 @@ describe('global test invariant host', () => { const fakeContext = { invariants: { register } } as unknown as Context for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) { const path = rawPath.replace(/^\.\.\//, '') - await companion.apply(fakeContext) + expect(companion.default, path).toBeUndefined() + const unwrapped = loader.unwrapExports(companion) as typeof companion + expect(unwrapped, path).toBe(companion) + expect(typeof unwrapped.name, path).toBe('string') + expect(unwrapped.inject, path).toContain('invariants') + expect(typeof unwrapped.apply, path).toBe('function') + await unwrapped.apply(fakeContext) const call = register.mock.calls.at(-1) if (call === undefined) throw new Error(`${path}: companion did not register`) registrations.set(path, call[0]) @@ -69,29 +77,11 @@ describe('global test invariant host', () => { expect(registrations).toEqual(owners) }) - it('limits manual composition to focused invariant topology tests', () => { - expect(MANUAL_INVARIANT_TESTS).toEqual([ - '/packages/support/invariants/tests/service.spec.ts', - '/packages/compact/compact/tests/invariant.spec.ts', - '/packages/context/time-context/tests/invariant.spec.ts', - '/packages/core/session/tests/invariant.spec.ts', - '/packages/core/agent/tests/invariant.spec.ts', - '/packages/core/scope/tests/invariant.spec.ts', - '/packages/core/agent-loop/tests/invariant.spec.ts', - '/packages/core/system-prompt/tests/invariant.spec.ts', - '/packages/core/tools/tests/invariant.spec.ts', - '/packages/fs/fs/tests/invariant.spec.ts', - '/packages/hooks/hook-protocol/tests/invariant.spec.ts', - '/packages/llm/llm/tests/invariant.spec.ts', - '/packages/llm/llm-retry/tests/invariant.spec.ts', - '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', - '/packages/subagent/subagent/tests/invariant.spec.ts', - '/packages/tasks/tasks/tests/invariant.spec.ts', - '/packages/todo/tool-todo/tests/invariant.spec.ts', - '/packages/ui/permission/tests/invariant.spec.ts', - '/packages/ui/user-approval/tests/invariant.spec.ts', - '/packages/workflow/workflow/tests/invariant.spec.ts', - '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', - ]) + it('recognizes focused invariant suites without a package inventory', () => { + expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true) + expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true) + expect(usesManualInvariantTree('C:\\repo\\packages\\support\\invariants\\tests\\service.spec.ts')).toBe(true) + expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true) + expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false) }) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 1f218e16f9..a3c16f1241 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -21,6 +21,7 @@ declare global { export interface TestInvariantCompanion { readonly name: string readonly inject: readonly string[] + readonly default?: unknown apply(ctx: Context): Promise<() => void> } @@ -28,28 +29,9 @@ export interface TestInvariantCompanion { export const testInvariantCompanions: Readonly> = import.meta.glob('../packages/*/*/src/invariant.ts', { eager: true }) -/** Tests that exercise selection or companion lifecycle with a deliberately hand-built service tree. */ -export const MANUAL_INVARIANT_TESTS = [ +/** Manual-topology suites whose names cannot follow the focused invariant convention. */ +const MANUAL_INVARIANT_TEST_EXCEPTIONS = [ '/packages/support/invariants/tests/service.spec.ts', - '/packages/compact/compact/tests/invariant.spec.ts', - '/packages/context/time-context/tests/invariant.spec.ts', - '/packages/core/session/tests/invariant.spec.ts', - '/packages/core/agent/tests/invariant.spec.ts', - '/packages/core/scope/tests/invariant.spec.ts', - '/packages/core/agent-loop/tests/invariant.spec.ts', - '/packages/core/system-prompt/tests/invariant.spec.ts', - '/packages/core/tools/tests/invariant.spec.ts', - '/packages/fs/fs/tests/invariant.spec.ts', - '/packages/hooks/hook-protocol/tests/invariant.spec.ts', - '/packages/llm/llm/tests/invariant.spec.ts', - '/packages/llm/llm-retry/tests/invariant.spec.ts', - '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', - '/packages/subagent/subagent/tests/invariant.spec.ts', - '/packages/tasks/tasks/tests/invariant.spec.ts', - '/packages/todo/tool-todo/tests/invariant.spec.ts', - '/packages/ui/permission/tests/invariant.spec.ts', - '/packages/ui/user-approval/tests/invariant.spec.ts', - '/packages/workflow/workflow/tests/invariant.spec.ts', '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', ] as const @@ -66,7 +48,8 @@ const hosts = new WeakMap() const originalPlugin = RegistryService.prototype.plugin RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) { - if (usesManualInvariantTree()) return originalPlugin.call(this, plugin, config, getOuterStack) + const testPath = expect.getState().testPath ?? '' + if (usesManualInvariantTree(testPath)) return originalPlugin.call(this, plugin, config, getOuterStack) const root = this.ctx.root const host = hosts.get(root) ?? startInvariantHost(root) @@ -83,9 +66,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge return joinInvariantStartup(fiber, host.ready) } -function usesManualInvariantTree(): boolean { - const testPath = expect.getState().testPath?.replaceAll('\\', '/') ?? '' - return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path)) +/** + * Detect focused suites that construct service selection or companion lifecycle explicitly. + * @param testPath - absolute or repo-relative Vitest file path. + * @returns whether the global invariant host must leave the root untouched. + */ +export function usesManualInvariantTree(testPath: string): boolean { + const normalized = testPath.replaceAll('\\', '/') + if (/\/packages\/[^/]+\/[^/]+\/tests\/[^/]*invariant[^/]*\.spec\.ts$/.test(normalized)) return true + return MANUAL_INVARIANT_TEST_EXCEPTIONS.some(path => normalized.endsWith(path)) } const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs new file mode 100644 index 0000000000..b696964f4f --- /dev/null +++ b/scripts/verify-built-package-invariants.mjs @@ -0,0 +1,51 @@ +/** Verify every compiled companion through its package self-reference under plain Node. */ + +import { spawnSync } from 'node:child_process' +import { globSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const root = resolve(import.meta.dirname, '..') +const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href +const failures = [] +const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() + +for (const manifestPath of manifests) { + const packageDir = dirname(resolve(root, manifestPath)) + const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) + const packageName = manifest.name + if (typeof packageName !== 'string' || packageName.length === 0) { + failures.push(`${manifestPath}: missing package name`) + continue + } + + const probe = ` + const companion = await import(${JSON.stringify(`${packageName}/invariant`)}); + const { default: Loader } = await import(${JSON.stringify(loaderUrl)}); + if ('default' in companion) throw new Error('companion has a default export'); + const loader = Object.create(Loader.prototype); + const unwrapped = loader.unwrapExports(companion); + if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace'); + if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing'); + if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) { + throw new Error('companion does not inject invariants'); + } + if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing'); + ` + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], { + cwd: packageDir, + encoding: 'utf8', + }) + if (result.status === 0) continue + const detail = result.error?.message + ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) + failures.push(`${packageName}: ${detail}`) +} + +if (failures.length > 0) { + console.error('verify-built-package-invariants: compiled companion failures:') + for (const failure of failures) console.error(` ${failure}`) + process.exit(1) +} + +console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`) From 9848fdf93e4cf35e905104152ad6a4eb377b3b7d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:25:51 +0800 Subject: [PATCH 22/30] refactor: remove unrelated helper extractions --- packages/sdk/scripts/src/args.ts | 5 ++-- packages/sdk/scripts/src/forwarding.ts | 21 ----------------- .../src/structured-protocol.ts | 10 -------- .../subagent-inprocess/src/structured.ts | 14 +++++++++-- .../support/agent-loop-testkit/src/index.ts | 18 ++++----------- packages/ui/app-boot/src/config-path.ts | 23 ------------------- packages/ui/app-boot/src/index.ts | 21 +++++++++++++++-- 7 files changed, 39 insertions(+), 73 deletions(-) delete mode 100644 packages/sdk/scripts/src/forwarding.ts delete mode 100644 packages/subagent/subagent-inprocess/src/structured-protocol.ts delete mode 100644 packages/ui/app-boot/src/config-path.ts diff --git a/packages/sdk/scripts/src/args.ts b/packages/sdk/scripts/src/args.ts index 448315a0ec..4d1ce867de 100644 --- a/packages/sdk/scripts/src/args.ts +++ b/packages/sdk/scripts/src/args.ts @@ -6,7 +6,6 @@ import { parseArgs as parseNodeArgs } from 'node:util' import { Command } from 'commander' -import { splitForwardedArgs } from './forwarding.ts' /** Commands implemented by the dsh-sdk launcher. */ type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' | 'create' @@ -35,7 +34,9 @@ export function parseDshSdkArgs(argv: readonly string[]): DshSdkArgs { if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { return { forwarded: [], help: true } } - const { launcher: launcherArgv, forwarded: passthrough } = splitForwardedArgs(argv) + const separator = argv.indexOf('--') + const launcherArgv = separator === -1 ? argv : argv.slice(0, separator) + const passthrough = separator === -1 ? [] : argv.slice(separator + 1) let parsed: DshSdkArgs | undefined const program = new Command() .name('dsh-sdk') diff --git a/packages/sdk/scripts/src/forwarding.ts b/packages/sdk/scripts/src/forwarding.ts deleted file mode 100644 index 56fa85f564..0000000000 --- a/packages/sdk/scripts/src/forwarding.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** Argument-delimiter handling shared by the SDK launcher and its invariant. */ - -/** Launcher-owned arguments and opaque arguments following `--`. */ -export interface ForwardedArgumentSplit { - /** Arguments parsed by the SDK launcher. */ - readonly launcher: readonly string[] - /** Arguments passed unchanged to the selected project command. */ - readonly forwarded: readonly string[] -} - -/** - * Split the first `--` delimiter without interpreting either side. - * @param argv - complete user argument vector. - * @returns launcher arguments and post-delimiter arguments. - */ -export function splitForwardedArgs(argv: readonly string[]): ForwardedArgumentSplit { - const separator = argv.indexOf('--') - return separator === -1 - ? { launcher: argv, forwarded: [] } - : { launcher: argv.slice(0, separator), forwarded: argv.slice(separator + 1) } -} diff --git a/packages/subagent/subagent-inprocess/src/structured-protocol.ts b/packages/subagent/subagent-inprocess/src/structured-protocol.ts deleted file mode 100644 index 8ae87e73b0..0000000000 --- a/packages/subagent/subagent-inprocess/src/structured-protocol.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Model-facing constants shared by structured child execution and its invariant. */ - -/** The model-facing tool name a structured child must call to finish. */ -export const STRUCTURED_OUTPUT_TOOL = 'structured_output' - -/** The terminal structured-result instruction appended to a child request. */ -export const STRUCTURED_OUTPUT_INSTRUCTION - = 'When you have your final answer, you MUST report it by calling the ' - + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` - + 'Do not finish with a plain text answer: only the tool call counts as your result.' diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index cecb7d173e..09aa2d24b7 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -15,9 +15,19 @@ import type { ContinuationStop } from '@deepseek-ai/dsh-agent' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools' -import { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } from './structured-protocol.ts' -export { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } from './structured-protocol.ts' +/** The model-facing tool name a structured child must call to finish. */ +export const STRUCTURED_OUTPUT_TOOL = 'structured_output' + +/** + * The instruction registered as the child's trailing (order-190, the end of + * the tool-guidance band) scoped prompt section: the demand travels with the + * tool, as ordinary prompt state of exactly one agent. + */ +export const STRUCTURED_OUTPUT_INSTRUCTION + = 'When you have your final answer, you MUST report it by calling the ' + + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` + + 'Do not finish with a plain text answer: only the tool call counts as your result.' /** One structured run's live handle: read the captured value once the child settles. */ export interface StructuredAttachment { diff --git a/packages/support/agent-loop-testkit/src/index.ts b/packages/support/agent-loop-testkit/src/index.ts index ca7148ee23..c7b0cb7304 100644 --- a/packages/support/agent-loop-testkit/src/index.ts +++ b/packages/support/agent-loop-testkit/src/index.ts @@ -6,7 +6,12 @@ */ import type { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' import type { Config as ToolRegistryConfig } from '@deepseek-ai/dsh-tools' /** Configuration forwarded to the prerequisite service plugins. */ @@ -33,19 +38,6 @@ export async function mountAgentLoopTestDependencies( ctx: Context, options: AgentLoopTestDependenciesOptions = {}, ): Promise { - const [ - { default: LlmService }, - { default: SessionStore }, - { default: SystemPrompt }, - { default: ToolRegistry }, - { default: AgentRegistry }, - ] = await Promise.all([ - import('@deepseek-ai/dsh-llm'), - import('@deepseek-ai/dsh-session'), - import('@deepseek-ai/dsh-system-prompt'), - import('@deepseek-ai/dsh-tools'), - import('@deepseek-ai/dsh-agent'), - ]) await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) diff --git a/packages/ui/app-boot/src/config-path.ts b/packages/ui/app-boot/src/config-path.ts deleted file mode 100644 index bdfb933feb..0000000000 --- a/packages/ui/app-boot/src/config-path.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Snapshot-aware application configuration path selection. @module @deepseek-ai/dsh-app-boot/config-path */ - -import { basename, dirname, resolve } from 'node:path' - -/** - * Resolve the config to boot. Replay swaps a `cordis.yml` basename for - * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. - * @param configPath - requested config path, absolute or relative to `cwd`. - * @param snapshotMode - bin `$DSH_SNAPSHOT`; only `replay` swaps the basename. - * @param cwd - base for a relative `configPath`. - * @returns the absolute path of the config to boot. - */ -export function resolveConfigPath( - configPath: string, - snapshotMode: string | undefined, - cwd: string = process.cwd(), -): string { - const absolute = resolve(cwd, configPath) - if (snapshotMode !== 'replay') return absolute - const dir = dirname(absolute) - const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') - return resolve(dir, replayName) -} diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 959a7120d7..e2413fa736 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -6,12 +6,29 @@ */ import { pathToFileURL } from 'node:url' -import { dirname, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' -export { resolveConfigPath } from './config-path.ts' +/** + * Resolve the config to boot. Replay swaps a `cordis.yml` basename for + * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. + * @param configPath - the requested config path (absolute, or relative to `cwd`). + * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the + * basename. + * @param cwd - the base a relative `configPath` resolves against. + * @returns the absolute path of the config to boot. + */ +export function resolveConfigPath( + configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(), +): string { + const absolute = resolve(cwd, configPath) + if (snapshotMode !== 'replay') return absolute + const dir = dirname(absolute) + const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') + return resolve(dir, replayName) +} /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the From d50e6d4b8e593f0459af77cb11ea31ee356caabf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:38:20 +0800 Subject: [PATCH 23/30] test(invariants): include goal session topology --- scripts/test-invariants.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index bc2e6835e4..a83f9c8676 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -82,6 +82,7 @@ describe('global test invariant host', () => { '/packages/core/tools/tests/invariant.spec.ts', '/packages/fs/fs/tests/invariant.spec.ts', '/packages/goal/goal/tests/invariant.spec.ts', + '/packages/goal/goal-session/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', '/packages/llm/llm-retry/tests/invariant.spec.ts', From 49a9bca9f6d68e22c76e3bad58ef78625194a102 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 21 Jul 2026 14:49:57 +0800 Subject: [PATCH 24/30] fix: validate compaction config pairs --- docs/config-catalog.md | 2 +- packages/compact/compact-basic/src/config.ts | 26 +++++++--- .../compact-basic/tests/compact-basic.spec.ts | 51 ++++++++++++++++++- .../tests/compact-loop-repro.spec.ts | 1 - .../tests/loader-composition.spec.ts | 17 ++++++- packages/llm/token-meter/src/index.ts | 4 +- packages/llm/token-meter/src/types.ts | 2 +- .../llm/token-meter/tests/token-meter.spec.ts | 9 +++- 8 files changed, 96 insertions(+), 16 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 55550cbe4a..28d7932fb0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1059,7 +1059,7 @@ Source: [`packages/context/time-context/src/index.ts:19`](../packages/context/ti ```ts config-catalog /** Token-meter plugin configuration; the fixed estimator has no settings. */ -export type TokenMeterConfig = object +export type TokenMeterConfig = Record ``` Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts) diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts index 90adb5b5f7..b954537af8 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -248,17 +248,29 @@ function validatePolicy( assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries) } - const summarizationProvider = config.summarizationProvider - const summarizationModel = config.summarizationModel - if (summarizationProvider !== undefined && typeof summarizationProvider !== 'string') { + validateSummarizationPair(config, name) +} + +/** Require one scope to omit, clear, or replace the summarization target as a pair. */ +function validateSummarizationPair( + config: CompactPolicyConfig | Record, + name: string, +): void { + const provider = config.summarizationProvider + const model = config.summarizationModel + if (provider !== undefined && typeof provider !== 'string') { throw new Error(`${name}.summarizationProvider must be a string`) } - if (summarizationModel !== undefined && typeof summarizationModel !== 'string') { + if (model !== undefined && typeof model !== 'string') { throw new Error(`${name}.summarizationModel must be a string`) } - if (((summarizationProvider ?? '').length === 0) - !== ((summarizationModel ?? '').length === 0)) { - throw new Error(`${name}: summarizationProvider and summarizationModel must both be empty or both be non-empty`) + if (provider === undefined && model === undefined) return + if (provider === undefined || model === undefined + || (provider.length === 0) !== (model.length === 0)) { + throw new Error( + `${name}: summarizationProvider and summarizationModel must be set together ` + + 'as an empty or non-empty pair', + ) } } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index cd1ebd2bd1..e6cc897576 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -308,6 +308,41 @@ describe('compact configuration and defaults', () => { }) }) + it('inherits, clears, and replaces the summarization target as a pair', () => { + const config = resolveConfig({ + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + modelPolicies: [ + { provider: 'inherit-provider', model: MODEL }, + { + provider: 'clear-provider', + model: MODEL, + summarizationProvider: '', + summarizationModel: '', + }, + { + provider: 'replace-provider', + model: MODEL, + summarizationProvider: 'replacement-provider', + summarizationModel: 'replacement-model', + }, + ], + }) + + expect(resolveTargetPolicy(config, { provider: 'inherit-provider', model: MODEL })) + .toMatchObject({ + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + }) + expect(resolveTargetPolicy(config, { provider: 'clear-provider', model: MODEL })) + .toMatchObject({ summarizationProvider: '', summarizationModel: '' }) + expect(resolveTargetPolicy(config, { provider: 'replace-provider', model: MODEL })) + .toMatchObject({ + summarizationProvider: 'replacement-provider', + summarizationModel: 'replacement-model', + }) + }) + it('validates common values and pressure-policy invariants', () => { const bad = [ [{ maxTokens: 0 }, /maxTokens/], @@ -316,8 +351,10 @@ describe('compact configuration and defaults', () => { [{ auto: 'yes' }, /auto must be a boolean/], [{ summarizationProvider: 1 }, /summarizationProvider must be a string/], [{ summarizationModel: 1 }, /summarizationModel must be a string/], - [{ summarizationProvider: MODEL }, /must both be empty or both be non-empty/], - [{ summarizationModel: MODEL }, /must both be empty or both be non-empty/], + [{ summarizationProvider: MODEL }, /must be set together/], + [{ summarizationModel: MODEL }, /must be set together/], + [{ summarizationProvider: '' }, /must be set together/], + [{ summarizationModel: '' }, /must be set together/], [{ thresholdRatio: 0 }, /number in \(0, 1\]/], [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/], [{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/], @@ -333,6 +370,16 @@ describe('compact configuration and defaults', () => { [{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/], [{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/], [{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/], + [{ + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + modelPolicies: [{ provider: MODEL, model: MODEL, summarizationModel: '' }], + }, /modelPolicies\[0\].*must be set together/], + [{ + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: '' }], + }, /modelPolicies\[0\].*must be set together/], [{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/], [ { modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] }, 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 9528683d2b..3326bd130c 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -133,7 +133,6 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr auto: true, thresholdRatio: 0.5, retainTokens: 50, - summarizationModel: '', maxTokens: 8192, compactionRetries: 1, }) diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index c80f12c4bb..74f8423e52 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -85,7 +85,7 @@ describe('real Loader composition', () => { context = new Context() await expect(context.plugin(TokenMeterService, { contextWindow: 4096, - })).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) + } as never)).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) }) it('rejects stale compact-basic config after Schemastery normalization', async () => { @@ -110,4 +110,19 @@ describe('real Loader composition', () => { }], })).rejects.toThrow(/modelPolicies\[0\]: retainRatio \(0.2\).*thresholdRatio \(0.1\)/) }) + + it('rejects an incomplete model-policy summarization pair during plugin load', async () => { + context = new Context() + await context.plugin(LlmService) + await context.plugin(TokenMeterService) + await expect(context.plugin(BasicCompactService, { + summarizationProvider: 'default-provider', + summarizationModel: 'default-model', + modelPolicies: [{ + provider: 'test-provider', + model: 'test-model', + summarizationModel: '', + }], + })).rejects.toThrow(/modelPolicies\[0\].*must be set together/) + }) }) diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 2fdfb064db..e17b0c06b6 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -80,7 +80,9 @@ declare module 'cordis' { /** Replay owner for one service-wide estimator and isolated per-session folds. */ export class TokenMeterService extends Service { - static Config: z = z.object({}) + // Schemastery preserves untrusted loader keys on an empty object schema; + // the public type excludes settings while validateConfigKeys rejects them. + static Config: z = z.object({}) as unknown as z private readonly states = new WeakMap() diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index 33aa3d4bb5..255425b639 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -7,7 +7,7 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm' /** Token-meter plugin configuration; the fixed estimator has no settings. */ -export type TokenMeterConfig = object +export type TokenMeterConfig = Record /** The baseline from which a signed surface delta produces current pressure. */ export type TokenMeasurementBaseline = diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index dc7d0c6e2e..13e196477a 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' @@ -87,10 +87,15 @@ function expectSurfaceTotal(measurement: TokenMeasurement): void { } describe('TokenMeterService configuration and registration', () => { + it('exposes an empty public configuration type', () => { + expectTypeOf<{}>().toExtend() + expectTypeOf<{ contextWindow: number }>().not.toExtend() + }) + it.each(['models', 'contextWindow', 'contextWidow'])( 'rejects stale or unknown top-level config key %s', (key) => { - expect(() => meter({ [key]: {} })) + expect(() => meter({ [key]: {} } as unknown as TokenMeterConfig)) .toThrow(`TokenMeterConfig: unknown key "${key}"`) }, ) From 504fbf59f3202126330517eb8b20f7f5a17c6f0c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:46:43 +0800 Subject: [PATCH 25/30] fix(invariants): replay histories and pack companions --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 4 +- ...-package-invariant-runtime-contracts.zh.md | 4 +- docs/module-graph.md | 3 +- packages/context/time-context/package.json | 1 + .../context/time-context/src/invariant.ts | 29 +++-- .../time-context/tests/invariant.spec.ts | 43 ++++++- packages/context/time-context/tsconfig.json | 3 + packages/goal/goal-session/tsdown.config.ts | 25 ++++ packages/goal/goal/tsdown.config.ts | 25 ++++ .../sandbox/sandbox-policy/src/invariant.ts | 24 ++-- .../sandbox-policy/tests/invariant.spec.ts | 17 ++- .../sandbox/sandbox-policy/tsdown.config.ts | 25 ++++ packages/todo/tool-todo/src/invariant.ts | 18 ++- .../todo/tool-todo/tests/invariant.spec.ts | 19 ++- packages/ui/permission/src/invariant.ts | 22 ++-- .../ui/permission/tests/invariant.spec.ts | 15 ++- scripts/verify-built-package-invariants.mjs | 115 +++++++++++++----- 18 files changed, 322 insertions(+), 74 deletions(-) create mode 100644 packages/goal/goal-session/tsdown.config.ts create mode 100644 packages/goal/goal/tsdown.config.ts create mode 100644 packages/sandbox/sandbox-policy/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 430e4e9f13..61d41ea571 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: 4063acedfae1586e6a85e3b14a45bafe894a7a1f -2026-07-19-package-invariant-runtime-contracts.zh.md: a74d47329116438c7adadb9c05bd0f3ca9549f56 +2026-07-19-package-invariant-runtime-contracts.md: 86064ed36866675424826f60db15831879e86ea1 +2026-07-19-package-invariant-runtime-contracts.zh.md: 6194e05d75dfce8cb5f7833b1d2e6f86abeb46f3 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index 4063acedfa..86064ed368 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -53,13 +53,13 @@ The current 100-package workspace has 21 executable companions and 79 justified | `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | | `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn, next pre-step position, and elapsed baseline; rendered time parses and does not postdate its event. | -Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. +Session-backed companions validate existing durable events when they load, using the prefix preceding each candidate where the relationship depends on event order. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. ### Repository gate and tests `verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. -Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate imports every compiled `./invariant` self-reference under plain Node and repeats that Loader-shape check. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. +Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate stages each package's exact `npm pack` file inventory, imports its compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so an unpublished shared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index a74d473291..6194e05d75 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -53,13 +53,13 @@ Status: implemented | `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | | `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn、下一个 step 开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 | -基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 +基于 session 的 companion 在加载时验证已有持久化事件;关系依赖事件顺序时,会使用每个候选事件之前的事件前缀。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 ### 仓库门禁与测试 `verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 -Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁在 plain Node 下导入每个已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 +Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁会按每个包的精确 `npm pack` 文件清单暂存文件,在 plain Node 下导入该包已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,未发布的共享运行时分片会在正式发布前导致门禁失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 ## 考虑过的替代方案 diff --git a/docs/module-graph.md b/docs/module-graph.md index 977662455b..4ab59607fd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -306,6 +306,7 @@ flowchart TD pkg_user_interaction --> pkg_llm pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_session pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -660,7 +661,7 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index ffbc3a87ee..2b3ad9fca6 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -32,6 +32,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index c3d291c7af..45fdb48cba 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -19,10 +19,10 @@ export const name = 'time-context-invariant' export const inject = ['invariants'] /** Derive the pre-step position at which a time-context reading may append. */ -function preparationPosition(session: Session, fail: InvariantFailure): { turn: number; step: number } { +function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } { const currentTurnEvents: SessionEvent[] = [] let openTurn: number | undefined - for (const event of session.events.slice().reverse()) { + for (const event of history.slice().reverse()) { if (event.type === 'turn/end') { fail('time-context reading must be appended inside an open turn') } @@ -47,7 +47,7 @@ function preparationPosition(session: Session, fail: InvariantFailure): { turn: /** Validate one plugin-attributed time reading against its session position and timestamp. */ function validateReading( - session: Session, + history: readonly SessionEvent[], event: SessionEvent<'context/message'>, fail: InvariantFailure, ): void { @@ -62,7 +62,7 @@ function validateReading( if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) { fail('time-context turn and step must be positive safe integers') } - const expected = preparationPosition(session, fail) + const expected = preparationPosition(history, fail) if (turn !== expected.turn || step !== expected.step) { fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`) } @@ -80,17 +80,30 @@ function validateReading( } } -/** Install validation for plugin-attributed context readings. */ -const install: InvariantInstaller = (ctx, fail) => { +/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ +/** Validate all package-owned readings already present in one session. */ +function validateSession(session: Session, fail: InvariantFailure): void { + for (const [index, event] of session.events.entries()) { + if (event.type !== 'context/message' + || event.data.source.kind !== 'plugin' + || event.data.source.plugin !== SOURCE_NAME) continue + validateReading(session.events.slice(0, index), event, fail) + } +} + +/** Install validation for loaded and newly appended context readings. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) validateSession(session, fail) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (event.type !== 'context/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return - validateReading(session, event, fail) + validateReading(session.events, event, fail) }, { global: true }) -} +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the time-context invariant companion. diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 4bcd0f6774..5da942603f 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -9,7 +9,8 @@ const SECOND = Date.parse('2026-07-14T00:00:00Z') async function setup(): Promise { const ctx = new Context() - await ctx.plugin(InvariantService) + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(TimeInvariant) return ctx } @@ -54,6 +55,13 @@ function preparing(turn: number, step: number): Session { return session } +function appendReading(session: Session, text: string): void { + session.append('context/message', { + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: 'time-context' }, + }, { surfaceOp: 'append' }) +} + describe('time-context invariants', () => { it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => { const ctx = await setup() @@ -69,6 +77,37 @@ describe('time-context invariants', () => { }).not.toThrow() }) + it('validates each existing reading against its preceding durable prefix', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('time-invariant-late-valid')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: 'prepare' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendReading(session, reading()) + session.append('step/start', { turn: 1, step: 1 }) + + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined() + }) + + it('rejects an invalid existing reading on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('time-invariant-late-invalid')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: 'prepare' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendReading(session, reading('1', '2', 'step context')) + + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(TimeInvariant).then(() => undefined)).rejects.toThrow(/expected turn 1\/step 1/) + }) + it.each([ [reading('1', '3', 'step context'), /expected turn 2\/step 3/], [reading('2', '2', 'step context'), /expected turn 2\/step 3/], diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index 491552def0..3f3c553e98 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -32,6 +32,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../core/session" } ] } diff --git a/packages/goal/goal-session/tsdown.config.ts b/packages/goal/goal-session/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/goal/goal-session/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/goal/goal/tsdown.config.ts b/packages/goal/goal/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/goal/goal/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index c7d5af742d..90b8bf65fd 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { SANDBOX_MODES } from './session-mode.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-policy' @@ -12,16 +12,26 @@ export const name = 'sandbox-policy-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install validation for the durable sandbox-mode vocabulary. */ -const install: InvariantInstaller = (ctx, fail) => { +/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ +/** Validate the package-owned event shape and ignore unrelated events. */ +function validateEvent(event: SessionEvent, fail: InvariantFailure): void { + if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) { + fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`) + } +} + +/** Install validation for loaded and newly appended sandbox modes. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) { + for (const event of session.events) validateEvent(event, fail) + } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const event = (args as [Session, SessionEvent])[1] - if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) { - fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`) - } + validateEvent(event, fail) }, { global: true }) -} +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register this package's invariant companion. diff --git a/packages/sandbox/sandbox-policy/tests/invariant.spec.ts b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts index 545625cc66..d3255b305e 100644 --- a/packages/sandbox/sandbox-policy/tests/invariant.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' import * as SandboxPolicyInvariant from '@deepseek-ai/dsh-sandbox-policy/invariant' async function setup(): Promise { const ctx = new Context() - await ctx.plugin(InvariantService) + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(SandboxPolicyInvariant) return ctx } @@ -37,4 +38,16 @@ describe('sandbox-policy invariants', () => { expect(() => { ctx.emit('session/event', {} as Session, modeEvent('host-root')) }) .toThrow(new InvariantError('@deepseek-ai/dsh-sandbox-policy', 'sandbox/mode carries unknown mode "host-root"')) }) + + it('rejects an unknown mode already present on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create().append('sandbox/mode', { mode: 'host-root' as never }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(SandboxPolicyInvariant).then(() => undefined)).rejects.toMatchObject({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-sandbox-policy', + }) + }) }) diff --git a/packages/sandbox/sandbox-policy/tsdown.config.ts b/packages/sandbox/sandbox-policy/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/sandbox/sandbox-policy/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 0b3a3739e4..d353c80f77 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -33,14 +33,24 @@ function validateTodos(value: unknown, fail: InvariantFailure): void { if (active > 1) fail(`todo/write contains ${active} in-progress entries; at most one is allowed`) } -/** Install validation for durable whole-list todo snapshots. */ -const install: InvariantInstaller = (ctx, fail) => { +/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ +/** Validate the package-owned event shape and ignore unrelated events. */ +function validateEvent(event: SessionEvent, fail: InvariantFailure): void { + if (event.type === 'todo/write') validateTodos(event.data.todos, fail) +} + +/** Install validation for loaded and newly appended whole-list todo snapshots. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) { + for (const event of session.events) validateEvent(event, fail) + } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const event = (args as [Session, SessionEvent])[1] - if (event.type === 'todo/write') validateTodos(event.data.todos, fail) + validateEvent(event, fail) }, { global: true }) -} +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ /** * Register the todo invariant companion. diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index 8593f60299..abfcd74b29 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TodoInvariant from '@deepseek-ai/dsh-tool-todo/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' async function setup(): Promise { const ctx = new Context() - await ctx.plugin(InvariantService) + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(TodoInvariant) return ctx } @@ -50,4 +51,18 @@ describe('todo snapshot invariants', () => { }) }).not.toThrow() }) + + it('rejects an invalid existing snapshot on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create().append('todo/write', { + todos: [ + { content: 'duplicate', status: 'pending' }, + { content: 'duplicate', status: 'completed' }, + ], + }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(TodoInvariant).then(() => undefined)).rejects.toThrow(/repeats content "duplicate"/) + }) }) diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts index 0be5942d24..b1290b7307 100644 --- a/packages/ui/permission/src/invariant.ts +++ b/packages/ui/permission/src/invariant.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-permission' @@ -11,16 +11,24 @@ export const name = 'permission-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Install validation that durable preset events remain resolvable. */ -const install: InvariantInstaller = Object.assign((ctx: Context, fail: (message: string) => never) => { +/** Validate the package-owned event shape and ignore unrelated events. */ +function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void { + if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) { + fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`) + } +} + +/** Install validation that loaded and newly appended preset events remain resolvable. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) { + for (const event of session.events) validateEvent(ctx, event, fail) + } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const event = (args as [Session, SessionEvent])[1] - if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) { - fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`) - } + validateEvent(ctx, event, fail) }, { global: true }) -}, { inject: ['permission'] }) +}, { inject: ['permission', 'sessions'] }) /** * Register the permission invariant companion. diff --git a/packages/ui/permission/tests/invariant.spec.ts b/packages/ui/permission/tests/invariant.spec.ts index 6488239d38..9b903dc6a5 100644 --- a/packages/ui/permission/tests/invariant.spec.ts +++ b/packages/ui/permission/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context, Service } from 'cordis' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -14,8 +14,9 @@ class PermissionProbe extends Service { async function setup(): Promise { const ctx = new Context() + await ctx.plugin(SessionStore) await ctx.plugin(PermissionProbe) - await ctx.plugin(InvariantService) + await ctx.plugin(InvariantService, { enabled: true }) await ctx.plugin(PermissionInvariant) return ctx } @@ -39,4 +40,14 @@ describe('permission invariants', () => { expect(() => { ctx.emit('session/event', {} as Session, presetEvent('missing')) }) .toThrow(/unknown preset "missing"/) }) + + it('rejects an unknown preset already present on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(PermissionProbe) + ctx.sessions.create().append('permission/preset', { preset: 'missing' }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(PermissionInvariant).then(() => undefined)).rejects.toThrow(/unknown preset "missing"/) + }) }) diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index b696964f4f..473f9ad9d2 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -1,7 +1,17 @@ -/** Verify every compiled companion through its package self-reference under plain Node. */ +/** Verify every packed companion through its package self-reference under plain Node. */ import { spawnSync } from 'node:child_process' -import { globSync, readFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + globSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, +} from 'node:fs' +import { tmpdir } from 'node:os' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -9,43 +19,82 @@ const root = resolve(import.meta.dirname, '..') const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href const failures = [] const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() +const stagingRoot = mkdtempSync(resolve(tmpdir(), 'dsh-built-package-invariants-')) -for (const manifestPath of manifests) { - const packageDir = dirname(resolve(root, manifestPath)) - const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) - const packageName = manifest.name - if (typeof packageName !== 'string' || packageName.length === 0) { - failures.push(`${manifestPath}: missing package name`) - continue - } - - const probe = ` - const companion = await import(${JSON.stringify(`${packageName}/invariant`)}); - const { default: Loader } = await import(${JSON.stringify(loaderUrl)}); - if ('default' in companion) throw new Error('companion has a default export'); - const loader = Object.create(Loader.prototype); - const unwrapped = loader.unwrapExports(companion); - if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace'); - if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing'); - if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) { - throw new Error('companion does not inject invariants'); +try { + for (const [index, manifestPath] of manifests.entries()) { + const packageDir = dirname(resolve(root, manifestPath)) + const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) + const packageName = manifest.name + if (typeof packageName !== 'string' || packageName.length === 0) { + failures.push(`${manifestPath}: missing package name`) + continue } - if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing'); - ` - const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], { - cwd: packageDir, - encoding: 'utf8', - }) - if (result.status === 0) continue - const detail = result.error?.message - ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) - failures.push(`${packageName}: ${detail}`) + + const pack = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: packageDir, + encoding: 'utf8', + }) + if (pack.status !== 0) { + const detail = pack.error?.message + ?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`) + failures.push(`${packageName}: ${detail}`) + continue + } + + let files + try { + const result = JSON.parse(pack.stdout) + files = result[0]?.files + if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory') + } catch (error) { + failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`) + continue + } + + const stagedPackageDir = resolve(stagingRoot, String(index)) + for (const file of files) { + if (typeof file.path !== 'string' + || (file.path !== 'package.json' && !file.path.startsWith('lib/'))) continue + const target = resolve(stagedPackageDir, file.path) + mkdirSync(dirname(target), { recursive: true }) + copyFileSync(resolve(packageDir, file.path), target) + } + const packageNodeModules = resolve(packageDir, 'node_modules') + if (existsSync(packageNodeModules)) { + symlinkSync(packageNodeModules, resolve(stagedPackageDir, 'node_modules'), 'dir') + } + + const probe = ` + const companion = await import(${JSON.stringify(`${packageName}/invariant`)}); + const { default: Loader } = await import(${JSON.stringify(loaderUrl)}); + if ('default' in companion) throw new Error('companion has a default export'); + const loader = Object.create(Loader.prototype); + const unwrapped = loader.unwrapExports(companion); + if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace'); + if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing'); + if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) { + throw new Error('companion does not inject invariants'); + } + if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing'); + ` + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], { + cwd: stagedPackageDir, + encoding: 'utf8', + }) + if (result.status === 0) continue + const detail = result.error?.message + ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) + failures.push(`${packageName}: ${detail}`) + } +} finally { + rmSync(stagingRoot, { recursive: true, force: true }) } if (failures.length > 0) { - console.error('verify-built-package-invariants: compiled companion failures:') + console.error('verify-built-package-invariants: packed companion failures:') for (const failure of failures) console.error(` ${failure}`) process.exit(1) } -console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`) +console.log(`verify-built-package-invariants: ${manifests.length} packed companion(s) passed plain-Node Loader checks.`) From 4e021b4686bd144610ec4ab1e6f34033c4ba0426 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:56:15 +0800 Subject: [PATCH 26/30] fix(ci): invoke npm portably on Windows --- scripts/verify-built-package-invariants.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 473f9ad9d2..26da80599c 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -20,6 +20,12 @@ const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).hre const failures = [] const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() const stagingRoot = mkdtempSync(resolve(tmpdir(), 'dsh-built-package-invariants-')) +const packArgs = ['pack', '--dry-run', '--json', '--ignore-scripts'] +// Windows cannot spawn npm's .cmd shim directly; setup-node installs this JS +// entrypoint beside node.exe, so the probe stays shell-free on every runner. +const npmInvocation = process.platform === 'win32' + ? [process.execPath, [resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), ...packArgs]] + : ['npm', packArgs] try { for (const [index, manifestPath] of manifests.entries()) { @@ -31,7 +37,7 @@ try { continue } - const pack = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + const pack = spawnSync(npmInvocation[0], npmInvocation[1], { cwd: packageDir, encoding: 'utf8', }) @@ -62,7 +68,11 @@ try { } const packageNodeModules = resolve(packageDir, 'node_modules') if (existsSync(packageNodeModules)) { - symlinkSync(packageNodeModules, resolve(stagedPackageDir, 'node_modules'), 'dir') + symlinkSync( + packageNodeModules, + resolve(stagedPackageDir, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ) } const probe = ` From d4b126862ea8778a5a8a4383d3d350bf2326fb58 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:11:24 +0800 Subject: [PATCH 27/30] fix(ci): preserve pnpm links in packed probes --- scripts/verify-built-package-invariants.mjs | 86 ++++++++++----------- 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs index 26da80599c..4b298946d1 100644 --- a/scripts/verify-built-package-invariants.mjs +++ b/scripts/verify-built-package-invariants.mjs @@ -3,15 +3,12 @@ import { spawnSync } from 'node:child_process' import { copyFileSync, - existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, - symlinkSync, } from 'node:fs' -import { tmpdir } from 'node:os' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -19,7 +16,6 @@ const root = resolve(import.meta.dirname, '..') const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href const failures = [] const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort() -const stagingRoot = mkdtempSync(resolve(tmpdir(), 'dsh-built-package-invariants-')) const packArgs = ['pack', '--dry-run', '--json', '--ignore-scripts'] // Windows cannot spawn npm's .cmd shim directly; setup-node installs this JS // entrypoint beside node.exe, so the probe stays shell-free on every runner. @@ -27,38 +23,41 @@ const npmInvocation = process.platform === 'win32' ? [process.execPath, [resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), ...packArgs]] : ['npm', packArgs] -try { - for (const [index, manifestPath] of manifests.entries()) { - const packageDir = dirname(resolve(root, manifestPath)) - const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) - const packageName = manifest.name - if (typeof packageName !== 'string' || packageName.length === 0) { - failures.push(`${manifestPath}: missing package name`) - continue - } +for (const manifestPath of manifests) { + const packageDir = dirname(resolve(root, manifestPath)) + const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) + const packageName = manifest.name + if (typeof packageName !== 'string' || packageName.length === 0) { + failures.push(`${manifestPath}: missing package name`) + continue + } - const pack = spawnSync(npmInvocation[0], npmInvocation[1], { - cwd: packageDir, - encoding: 'utf8', - }) - if (pack.status !== 0) { - const detail = pack.error?.message - ?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`) - failures.push(`${packageName}: ${detail}`) - continue - } + const pack = spawnSync(npmInvocation[0], npmInvocation[1], { + cwd: packageDir, + encoding: 'utf8', + }) + if (pack.status !== 0) { + const detail = pack.error?.message + ?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`) + failures.push(`${packageName}: ${detail}`) + continue + } - let files - try { - const result = JSON.parse(pack.stdout) - files = result[0]?.files - if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory') - } catch (error) { - failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`) - continue - } + let files + try { + const result = JSON.parse(pack.stdout) + files = result[0]?.files + if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory') + } catch (error) { + failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`) + continue + } - const stagedPackageDir = resolve(stagingRoot, String(index)) + // Keep the packed view below its owning package so Node reaches the real + // pnpm dependency links. Junctioning node_modules elsewhere breaks pnpm's + // relative workspace links on Windows. + const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-packed-invariant-')) + try { for (const file of files) { if (typeof file.path !== 'string' || (file.path !== 'package.json' && !file.path.startsWith('lib/'))) continue @@ -66,14 +65,6 @@ try { mkdirSync(dirname(target), { recursive: true }) copyFileSync(resolve(packageDir, file.path), target) } - const packageNodeModules = resolve(packageDir, 'node_modules') - if (existsSync(packageNodeModules)) { - symlinkSync( - packageNodeModules, - resolve(stagedPackageDir, 'node_modules'), - process.platform === 'win32' ? 'junction' : 'dir', - ) - } const probe = ` const companion = await import(${JSON.stringify(`${packageName}/invariant`)}); @@ -92,13 +83,14 @@ try { cwd: stagedPackageDir, encoding: 'utf8', }) - if (result.status === 0) continue - const detail = result.error?.message - ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) - failures.push(`${packageName}: ${detail}`) + if (result.status !== 0) { + const detail = result.error?.message + ?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`) + failures.push(`${packageName}: ${detail}`) + } + } finally { + rmSync(stagedPackageDir, { recursive: true, force: true }) } -} finally { - rmSync(stagingRoot, { recursive: true, force: true }) } if (failures.length > 0) { From 1cee25fdeab3c965b9ca98e760dbea8991da4e1a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:45:27 +0800 Subject: [PATCH 28/30] fix(tui): resolve context capacity for active model --- packages/ui/tui/README.md | 2 +- packages/ui/tui/src/index.ts | 37 ++++++++++++++++++++++++++++--- packages/ui/tui/tests/harness.ts | 8 +++++-- packages/ui/tui/tests/tui.spec.ts | 30 +++++++++++++++++++++++-- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 4cfa958e8e..18fda35648 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -8,7 +8,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view shows token-meter context occupancy, tool-card mode, and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 34263ffadd..137ef7a264 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -737,7 +737,7 @@ class FooterComponent implements Component { private readonly showReasoning: () => boolean, private readonly tokens: () => { input: number; output: number }, private readonly currentModel: () => string | undefined, - private readonly contextPercent: () => number, + private readonly contextPercent: () => number | undefined, private readonly runningSeconds: () => number, ) {} @@ -755,7 +755,8 @@ class FooterComponent implements Component { const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}` const model = displayText(this.currentModel() ?? 'model unset') const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})` - const context = `${this.contextPercent()}% context` + const contextPercent = this.contextPercent() + const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context` const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}` const compactRight = `${context} ${modelState}` if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) { @@ -1058,6 +1059,11 @@ export function createTuiChat( let activeQuestion: PendingQuestion | undefined let modelOverlay: OverlayHandle | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } + let contextWindow: number | undefined + let contextResolution: Promise< + | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } + | { readonly kind: 'error'; readonly error: unknown } + > | undefined let modelCommands = Promise.resolve() const now = (): number => runtime.now?.() ?? Date.now() @@ -1070,7 +1076,9 @@ export function createTuiChat( () => showReasoning, () => tokens, () => target.current?.model, - () => Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / ctx.tokenMeter.contextWindow * 100)), + () => contextWindow === undefined + ? undefined + : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), () => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)), ) ui.addChild(header) @@ -1096,12 +1104,34 @@ export function createTuiChat( const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target) + const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { + contextWindow = undefined + const resolution = selected === undefined + ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) + : ctx.llm.resolveModelContext(selected.provider, selected.model).then( + context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const), + (error: unknown) => ({ kind: 'error', error } as const), + ) + contextResolution = resolution + void resolution.then((result) => { + if (contextResolution !== resolution) return + if (result.kind === 'error') { + appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') + return + } + contextWindow = result.contextWindow + requestRender() + }) + } + resolveContextWindow(target.current) + const selectModel = (selected: ModelChoice): void => { if (target.current?.provider === selected.provider && target.current.model === selected.model) { appendNotice(`Model is already ${targetLabel(selected)}.`) return } target.current = { provider: selected.provider, model: selected.model } + resolveContextWindow(target.current) appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`) } @@ -1432,6 +1462,7 @@ export function createTuiChat( const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true + contextResolution = undefined clearStatus() modelOverlay?.hide() modelOverlay = undefined diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 7bdd8d479f..fe37afe72e 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,7 +1,7 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -31,6 +31,7 @@ export interface TuiHarnessOptions { providers: LlmProviderInfo[] models: LlmModelInfo[] listModels?: (provider: string) => Promise + resolveModelContext?: (provider: string, model: string) => Promise } } @@ -75,9 +76,12 @@ export async function createTuiTestHarness model.provider === provider).map(model => ({ ...model }))) }, + resolveModelContext(provider: string, model: string) { + return catalog.resolveModelContext?.(provider, model) + ?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 }) + }, } as never) ctx.provide('tokenMeter', { - contextWindow: options.contextWindow ?? 128_000, measure() { return { totalTokens: options.contextTokens ?? 0 } }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e331c74b17..1a2e70049e 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -115,7 +115,6 @@ async function dispose(setupResult: Awaited>): Promise< function provideTokenMeter(ctx: Context): void { ctx.provide('tokenMeter', { - contextWindow: 128_000, measure() { return { totalTokens: 0 } }, @@ -537,8 +536,10 @@ describe('pi-tui chat lifecycle and transcript', () => { }) it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => { + const initialContext = Promise.withResolvers<{ contextWindow: number }>() const result = await setup({ agentOptions: { provider: 'alpha', model: 'a1' }, + contextTokens: 50, catalog: { providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }], models: [ @@ -547,6 +548,9 @@ describe('pi-tui chat lifecycle and transcript', () => { { provider: 'beta', id: 'b1', name: 'Beta One' }, { provider: 'beta', id: 'shared', name: 'Beta Shared' }, ], + resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1' + ? initialContext.promise + : Promise.resolve({ contextWindow: 200 }), }, }) @@ -574,6 +578,9 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Model selected: beta/b1') expect(result.agent.sent).toEqual([]) expect(result.agent.steered).toEqual([]) + initialContext.resolve({ contextWindow: 100 }) + await tick() + expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)') result.terminal.send('/model') result.terminal.send('\r') @@ -584,7 +591,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.agent.status = 'idle' result.ctx.emit('agent/status', result.agent, 'idle') await tick() - expect(result.terminal.output).toContain('tools:compact b1(reasoning:on)') + expect(result.terminal.output).toContain('25% context tools:compact b1(reasoning:on)') const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' }) @@ -620,6 +627,7 @@ describe('pi-tui chat lifecycle and transcript', () => { catalog: { providers: [{ id: 'alpha', name: 'Alpha' }], models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], + resolveModelContext: () => Promise.resolve(undefined), }, }) unset.terminal.send('/model') @@ -628,6 +636,7 @@ describe('pi-tui chat lifecycle and transcript', () => { unset.terminal.send('\r') await tick() expect(unset.terminal.output).toContain('Model selected: alpha/a1') + expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)') await dispose(unset) const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } }) @@ -649,12 +658,14 @@ describe('pi-tui chat lifecycle and transcript', () => { providers: [{ id: 'deepseek', name: 'DeepSeek' }], models: [], listModels: () => Promise.reject(new Error('catalog offline')), + resolveModelContext: () => Promise.reject(new Error('capacity offline')), }, }) failed.terminal.send('/model') failed.terminal.send('\r') await tick() expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline') + expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline') await dispose(failed) }) @@ -690,6 +701,21 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(rejectedResult.terminal.output).not.toContain('late catalog failure') await rejectedResult.ctx.fiber.dispose() + + const context = Promise.withResolvers<{ contextWindow: number }>() + const contextResult = await setup({ + contextTokens: 99, + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + resolveModelContext: () => context.promise, + }, + }) + await contextResult.controller.dispose() + context.resolve({ contextWindow: 100 }) + await tick() + expect(contextResult.terminal.output).not.toContain('99% context') + await contextResult.ctx.fiber.dispose() }) it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => { From 39c9bf3ef2d617b1fd520d63b96f8179c868d811 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:57:25 +0800 Subject: [PATCH 29/30] fix(llm-replay): publish recorded model capacity --- .../tui-agent/tests/fixtures/tui-scripted-llm.ts | 6 +++++- examples/tui-agent/tests/tui.snapshot.ts | 2 +- packages/support/llm-replay/README.md | 3 ++- packages/support/llm-replay/src/index.ts | 12 +++++++++++- packages/support/llm-replay/tests/llm-replay.spec.ts | 6 +++++- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 020394ebe3..2806e205a4 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -1,5 +1,5 @@ import type { Context } from 'cordis' -import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' @@ -25,6 +25,10 @@ class ScriptedTuiAdapter extends LlmAdapter { ]) } + override resolveModelContext(_provider: string, _model: string): Promise { + return Promise.resolve({ contextWindow: 128_000 }) + } + override async * stream(options: GenerateOptions): AsyncIterable { if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index cc8e9847cb..1ac3f5b55e 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -33,7 +33,7 @@ import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-termin const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') // Keep pre-normalization layout widths identical across macOS and Linux. const SNAPSHOT_TMP_ROOT = process.platform === 'win32' ? tmpdir() : '/tmp' -const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash' }] }] +const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi type SnapshotMode = 'replay' | 'record' | 'refresh' diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 4c8966333b..0d89d4337d 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -23,7 +23,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s | `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | -| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Configured routes dispatch through the replay adapter and never perform provider I/O. | +| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. | ```yaml - id: llm-replay @@ -34,6 +34,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s name: DeepSeek models: - id: deepseek-v4-flash + contextWindow: 128000 - id: deepseek-v4-pro # file/overrideFile/childFiles default to $DSH_SNAPSHOT_FILE / # $DSH_SNAPSHOT_OVERRIDE / $DSH_SNAPSHOT_CHILD_FILES, set by the snapshot diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 3299905a07..5a254cca2d 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -10,7 +10,7 @@ import { existsSync, readFileSync } from 'node:fs' import { delimiter as pathDelimiter } from 'node:path' import type { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelContext, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm' /** @@ -31,6 +31,8 @@ export interface ReplayModelConfig { name?: string /** Optional selector description. */ description?: string + /** Optional positive integer context capacity published by the replay adapter. */ + contextWindow?: number } /** One provider route exposed by the replay adapter. */ @@ -260,6 +262,14 @@ class ReplayAdapter extends LlmAdapter { }))) } + override resolveModelContext(provider: string, model: string): Promise { + const configured = this.providers.get(provider) + /* v8 ignore next -- LlmService only asks about routes registered from this same map. */ + if (configured === undefined) return Promise.resolve(undefined) + const contextWindow = configured.models?.find(candidate => candidate.id === model)?.contextWindow + return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + } + override stream(options: GenerateOptions): AsyncIterable { return this.replay(options) } diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 846085deec..b9a05e47ea 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -228,7 +228,7 @@ describe('installLlmReplay (through the real LlmService)', () => { id: 'deepseek', name: 'DeepSeek', models: [ - { id: 'flash' }, + { id: 'flash', contextWindow: 128_000 }, { id: 'pro', name: 'Pro', description: 'Larger model' }, ], }, @@ -245,6 +245,10 @@ describe('installLlmReplay (through the real LlmService)', () => { { provider: 'deepseek', id: 'pro', name: 'Pro', description: 'Larger model' }, ]) await expect(ctx.llm.listModels('empty')).resolves.toEqual([]) + await expect(ctx.llm.resolveModelContext('deepseek', 'flash')).resolves.toEqual({ contextWindow: 128_000 }) + await expect(ctx.llm.resolveModelContext('deepseek', 'pro')).resolves.toBeUndefined() + await expect(ctx.llm.resolveModelContext('deepseek', 'unlisted')).resolves.toBeUndefined() + await expect(ctx.llm.resolveModelContext('empty', 'unlisted')).resolves.toBeUndefined() expect(await drain(ctx.llm.stream({ provider: 'deepseek', model: 'pro', messages: [] }))).toEqual(TEXT_CHUNKS) dispose() From 373bf333ac23aedbcd356545d3fc84639b3eca5d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:05:40 +0800 Subject: [PATCH 30/30] docs: refresh config catalog for replay capacity --- docs/config-catalog.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1fe30bad4e..a4c27804c4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -608,10 +608,12 @@ export interface ReplayModelConfig { name?: string /** Optional selector description. */ description?: string + /** Optional positive integer context capacity published by the replay adapter. */ + contextWindow?: number } ``` -Source: [`packages/support/llm-replay/src/index.ts:375`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:385`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry`